Compare commits

...

21 Commits

Author SHA1 Message Date
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
345a95f48d fix(confluence): prevent content erasure on page/blogpost update and fix space update (#3356)
- Add body-format=storage to GET-before-PUT for page and blogpost updates
  (without this, Confluence v2 API does not return body content, causing
  the fallback to erase content when only updating the title)
- Fetch current space name when updating only description (Confluence API
  requires name on PUT, so we preserve the existing name automatically)
2026-02-26 14:52:57 -08:00
Waleed
e07963f88c chore(db): drop 8 redundant indexes and add partial index for stale execution cleanup (#3354) 2026-02-26 13:17:39 -08:00
Waleed
25c59e3e2e feat(devin): add devin integration for autonomous coding sessions (#3352)
* feat(devin): add devin integration for autonomous coding sessions

* lint

* improvement(devin): update tool names and add manual docs description

* improvement(devin): rename tool files to snake_case and regenerate docs

* regen docs

* fix(devin): remove redundant Number() conversions in tool request bodies
2026-02-26 11:57:50 -08:00
Waleed
dde098e8e5 fix: prevent raw workflowInput from overwriting coerced start block values (#3347)
buildUnifiedStartOutput and buildIntegrationTriggerOutput first populate
output with schema-coerced structuredInput values (via coerceValue), then
iterate workflowInput and unconditionally overwrite those keys with raw
strings. This causes typed values (arrays, objects, numbers, booleans)
passed to child workflows to arrive as stringified versions.

Add a structuredKeys guard so the workflowInput loop skips keys already
set by the coerced structuredInput, letting coerceValue's type-aware
parsing (JSON.parse for objects/arrays, Number() for numbers, etc.)
take effect.

Fixes #3105

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:13:19 -08:00
Waleed
5ae0115444 feat(sidebar): add lock/unlock to workflow registry context menu (#3350)
* feat(sidebar): add lock/unlock to workflow registry context menu

* docs(tools): add manual descriptions to google_books and table

* docs(tools): add manual descriptions to google_bigquery and google_tasks

* fix(sidebar): avoid unnecessary store subscriptions and fix mixed lock state toggle

* fix(sidebar): use getWorkflowLockToggleIds utility for lock toggle

Replaces manual pivot-sorting logic with the existing utility function,
which handles block ordering and no-op guards consistently.

* lint
2026-02-25 23:40:30 -08:00
Waleed
fbafe204e5 fix(confluence): add input validation for SSRF-flagged parameters (#3351) 2026-02-25 23:35:45 -08:00
Waleed
ba7d6ff298 fix(credential-selector): remove reserved icon space when no credential selected (#3348) 2026-02-25 22:29:35 -08:00
Waleed
40016e79a1 feat(google-tasks): add Google Tasks integration (#3342)
* feat(google-tasks): add Google Tasks integration

* fix(google-tasks): return actual taskId in delete response

* fix(google-tasks): use absolute imports and fix registry order

* fix(google-tasks): rename list-task-lists to list_task_lists for doc generator

* improvement(google-tasks): destructure task and taskList outputs with typed schemas

* ran lint

* improvement(google-tasks): add wandConfig for due date timestamp generation
2026-02-25 21:52:34 -08:00
Waleed
e4fb8b2fdd feat(bigquery): add Google BigQuery integration (#3341)
* feat(bigquery): add Google BigQuery integration

* fix(bigquery): add auth provider, fix docsLink and insertedRows count

* fix(bigquery): set pageToken visibility to user-or-llm for pagination

* fix(bigquery): use prefixed export names to avoid aliased imports

* lint

* improvement(bigquery): destructure tool outputs with structured array/object types

* lint
2026-02-25 19:31:06 -08:00
Waleed
d98545d554 fix(terminal): thread executionOrder through child workflow SSE events for loop support (#3346)
* fix(terminal): thread executionOrder through child workflow SSE events for loop support

* ran lint

* fix(terminal): render iteration children through EntryNodeRow for workflow block expansion

IterationNodeRow was rendering all children as flat BlockRow components,
ignoring nodeType. Workflow blocks inside loop iterations were never
rendered as WorkflowNodeRow, so they had no expand chevron or child tree.

* fix(terminal): add childWorkflowBlockId to matchesEntryForUpdate

Sub-executors reset executionOrderCounter, so child blocks across loop
iterations share the same blockId + executionOrder. Without checking
childWorkflowBlockId, updateConsole for iteration N overwrites entries
from iterations 0..N-1, causing all child blocks to be grouped under
the last iteration's workflow instance.
2026-02-25 19:02:44 -08:00
Waleed
fadbad4085 feat(confluence): add get user by account ID tool (#3345)
* feat(confluence): add get user by account ID tool

* feat(confluence): add missing tools for tasks, blog posts, spaces, descendants, permissions, and properties

Add 16 new Confluence operations: list/get/update tasks, update/delete blog posts,
create/update/delete spaces, get page descendants, list space permissions,
list/create/delete space properties. Includes API routes, tool definitions,
block config wiring, OAuth scopes, and generated docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(confluence): add missing OAuth scopes to auth.ts provider config

The OAuth authorization flow uses scopes from auth.ts, not oauth.ts.
The 9 new scopes were only added to oauth.ts and the block config but
not to the actual provider config in auth.ts, causing re-auth to still
return tokens without the new scopes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(confluence): fix truncated get_user tool description in docs

Remove apostrophe from description that caused MDX generation to
truncate at the escape character.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(confluence): address PR review feedback

- Move get_user from GET to POST to avoid exposing access token in URL
- Add 400 validation for missing params in space-properties create/delete
- Add null check for blog post version before update to prevent TypeError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(confluence): add missing response fields for descendants and tasks

- Add type and depth fields to page descendants (from Confluence API)
- Add body field (storage format) to task list/get/update responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(confluence): use validatePathSegment for Atlassian account IDs

validateAlphanumericId rejects valid Atlassian account IDs that contain
colons (e.g. 557058:6b9c9931-4693-49c1-8b3a-931f1af98134). Use
validatePathSegment with a custom pattern allowing colons instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ran lint

* update mock

* upgrade turborepo

* fix(confluence): reject empty update body for space PUT

Return 400 when neither name nor description is provided for space
update, instead of sending an empty body to the Confluence API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(confluence): remove spaceId requirement for create_space and fix list_tasks pagination

- Remove create_space from spaceId condition array since creating a space
  doesn't require a space ID input
- Remove list_tasks from generic supportsCursor array so it uses its
  dedicated handler that correctly passes assignedTo and status filters
  during pagination

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ran lint

* fixed type errors

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:16:53 -08:00
Waleed
244e1ee495 feat(workflow): lock/unlock workflow from context menu and panel (#3336)
* feat(workflow): lock/unlock workflow from context menu and panel

* lint

* fix(workflow): prevent duplicate lock notifications, no-op guard, fix orphaned JSDoc

* improvement(workflow): memoize hasLockedBlocks to avoid inline recomputation

* feat(google-translate): add Google Translate integration (#3337)

* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool

* feat(confluence): return page content in get page version tool (#3344)

* feat(confluence): return page content in get page version tool

* lint

* feat(api): audit log read endpoints for admin and enterprise (#3343)

* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint

* unified list of languages for google translate

* fix(workflow): respect snapshot view for panel lock toggle, remove unused disableAdmin prop

* improvement(canvas-menu): remove lock icon from workflow lock toggle

* feat(audit): record audit log for workflow lock/unlock
2026-02-25 15:23:30 -08:00
Waleed
1f3dc52d15 feat(api): audit log read endpoints for admin and enterprise (#3343)
* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint
2026-02-25 13:46:37 -08:00
Waleed
f625482bcb feat(confluence): return page content in get page version tool (#3344)
* feat(confluence): return page content in get page version tool

* lint
2026-02-25 13:45:19 -08:00
Waleed
16f337f6fd feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)
* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool
2026-02-25 13:38:35 -08:00
Waleed
063ec87ced feat(google-translate): add Google Translate integration (#3337)
* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file
2026-02-25 13:24:22 -08:00
Waleed
870d4b55c6 fix(templates): show description tagline on template cards (#3335) 2026-02-25 12:10:22 -08:00
Waleed
95304b2941 feat(google-sheets): add filter support to read operation (#3333)
* feat(google-sheets): add filter support to read operation

* ran lint
2026-02-25 11:34:12 -08:00
Waleed
8b0c47b06c chore(executor): extract shared utils and remove dead code from handlers (#3334) 2026-02-25 11:28:16 -08:00
Vikhyath Mondreti
774771fddd fix(call-chain): x-sim-via propagation for API blocks and MCP tools (#3332)
* fix(call-chain): x-sim-via propagation for API blocks and MCP tools

* addres bugbot comment
2026-02-25 08:41:54 -08:00
159 changed files with 24225 additions and 786 deletions

4
.gitignore vendored
View File

@@ -73,3 +73,7 @@ start-collector.sh
## Helm Chart Tests
helm/sim/test
i18n.cache
## Claude Code
.claude/launch.json
.claude/worktrees/

View File

@@ -939,6 +939,25 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DevinIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 500 500' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M59.29,209.39l48.87,28.21c1.75,1.01,3.71,1.51,5.67,1.51c1.95,0,3.92-0.52,5.67-1.51l48.87-28.21c0,0,0.14-0.11,0.2-0.16c0.74-0.45,1.44-0.99,2.07-1.6c0.09-0.09,0.18-0.2,0.27-0.29c0.54-0.58,1.03-1.21,1.44-1.89c0.06-0.11,0.16-0.2,0.2-0.32c0.43-0.74,0.74-1.53,0.99-2.37c0.05-0.18,0.09-0.36,0.14-0.54c0.2-0.86,0.36-1.74,0.36-2.66v-28.21c0-10.89,5.87-21.03,15.3-26.48c9.42-5.45,21.15-5.44,30.59,0l24.43,14.11c0.79,0.45,1.62,0.77,2.47,1.01c0.18,0.05,0.37,0.11,0.54,0.16c0.83,0.2,1.69,0.32,2.54,0.34c0.05,0,0.09,0,0.11,0c0.09,0,0.18-0.05,0.26-0.05c0.79,0,1.58-0.11,2.34-0.32c0.14-0.03,0.27-0.05,0.4-0.09c0.83-0.23,1.64-0.57,2.41-0.99c0.06-0.05,0.16-0.05,0.23-0.09l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81V64.52c0-4.05-2.16-7.78-5.67-9.81l-48.91-28.19c-3.51-2.03-7.81-2.03-11.32,0l-48.87,28.21c0,0-0.14,0.11-0.2,0.16c-0.74,0.45-1.44,0.99-2.07,1.6c-0.09,0.09-0.18,0.2-0.27,0.29c-0.54,0.58-1.03,1.21-1.44,1.89c-0.06,0.11-0.16,0.2-0.2,0.31c-0.43,0.74-0.74,1.53-0.99,2.37c-0.05,0.18-0.09,0.36-0.14,0.54c-0.2,0.86-0.36,1.74-0.36,2.66v28.21c0,10.89-5.87,21.03-15.3,26.5c-9.42,5.44-21.15,5.44-30.59,0l-24.42-14.1c-0.79-0.45-1.63-0.77-2.47-1.01c-0.18-0.05-0.36-0.11-0.54-0.16c-0.84-0.2-1.69-0.31-2.55-0.34c-0.14,0-0.25,0-0.38,0c-0.81,0-1.6,0.11-2.37,0.31c-0.14,0.02-0.25,0.05-0.38,0.09c-0.82,0.23-1.63,0.57-2.4,1c-0.06,0.05-0.16,0.05-0.23,0.09l-48.84,28.24c-3.51,2.03-5.67,5.76-5.67,9.81v56.42c0,4.05,2.16,7.78,5.67,9.81C59.29,209.41,59.29,209.39,59.29,209.39z'
fill='#2A6DCE'
/>
<path
d='M325.46,223.49c9.42-5.44,21.15-5.44,30.59,0l24.43,14.11c0.79,0.45,1.62,0.77,2.47,1.01c0.18,0.05,0.36,0.11,0.54,0.16c0.83,0.2,1.69,0.31,2.54,0.34c0.05,0,0.09,0,0.11,0c0.09,0,0.18-0.03,0.26-0.05c0.79,0,1.58-0.11,2.34-0.31c0.14-0.03,0.27-0.05,0.4-0.09c0.83-0.23,1.62-0.57,2.41-0.99c0.06-0.05,0.16-0.05,0.25-0.09l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81l-48.84-28.22c-3.51-2.03-7.81-2.03-11.32,0l-48.87,28.21c0,0-0.14,0.11-0.2,0.16c-0.74,0.45-1.44,0.99-2.07,1.6c-0.09,0.09-0.18,0.2-0.26,0.29c-0.54,0.58-1.03,1.21-1.44,1.89c-0.06,0.11-0.16,0.2-0.2,0.32c-0.43,0.74-0.74,1.53-0.99,2.37c-0.05,0.18-0.09,0.36-0.14,0.54c-0.2,0.86-0.36,1.74-0.36,2.66v28.21c0,10.89-5.87,21.03-15.3,26.5c-9.42,5.44-21.15,5.44-30.59,0l-24.43-14.11c-0.79-0.45-1.62-0.77-2.47-1.01c-0.18-0.05-0.36-0.11-0.54-0.16c-0.83-0.2-1.69-0.32-2.54-0.34c-0.14,0-0.25,0-0.38,0c-0.81,0-1.6,0.11-2.37,0.32c-0.14,0.03-0.25,0.05-0.38,0.09c-0.83,0.23-1.64,0.57-2.41,0.99c-0.06,0.05-0.16,0.05-0.23,0.09l-48.87,28.21c-3.51,2.03-5.67,5.76-5.67,9.81v56.43c0,4.05,2.16,7.78,5.67,9.81l48.87,28.21c0,0,0.16,0.05,0.23,0.09c0.77,0.43,1.58,0.77,2.41,0.99c0.14,0.05,0.27,0.05,0.4,0.09c0.77,0.18,1.55,0.29,2.34,0.32c0.09,0,0.18,0.05,0.27,0.05c0.05,0,0.09,0,0.11,0c0.86,0,1.69-0.14,2.54-0.34c0.18-0.05,0.36-0.09,0.54-0.16c0.86-0.25,1.69-0.57,2.47-1.01l24.43-14.11c9.42-5.44,21.15-5.44,30.59,0c9.42,5.44,15.3,15.59,15.3,26.48v28.21c0,0.92,0.14,1.8,0.36,2.66c0.05,0.18,0.09,0.36,0.14,0.54c0.25,0.83,0.56,1.62,0.99,2.37c0.06,0.11,0.14,0.2,0.2,0.31c0.4,0.68,0.9,1.31,1.44,1.89c0.09,0.09,0.18,0.2,0.26,0.29c0.61,0.6,1.31,1.12,2.07,1.6c0.06,0.05,0.11,0.11,0.2,0.16l48.87,28.21c1.75,1.01,3.72,1.51,5.67,1.51s3.92-0.52,5.67-1.51l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81l-48.87-28.21c0,0-0.16-0.05-0.23-0.09c-0.77-0.43-1.58-0.77-2.41-0.99c-0.14-0.05-0.25-0.05-0.38-0.09c-0.79-0.18-1.57-0.29-2.38-0.32c-0.11,0-0.25,0-0.36,0c-0.86,0-1.71,0.14-2.54,0.34c-0.18,0.05-0.34,0.09-0.52,0.16c-0.86,0.25-1.69,0.57-2.47,1.01l-24.43,14.11c-9.42,5.44-21.15,5.44-30.58,0c-9.42-5.44-15.3-15.59-15.3-26.5c0-10.91,5.87-21.03,15.3-26.48C325.55,223.49,325.46,223.49,325.46,223.49z'
fill='#1DC19C'
/>
<path
d='M304.5,369.22l-48.87-28.21c0,0-0.16-0.05-0.23-0.09c-0.77-0.43-1.57-0.77-2.41-0.99c-0.14-0.05-0.27-0.05-0.4-0.09c-0.79-0.18-1.57-0.29-2.37-0.32c-0.14,0-0.25,0-0.38,0c-0.86,0-1.71,0.14-2.54,0.34c-0.18,0.05-0.34,0.09-0.52,0.16c-0.86,0.25-1.69,0.57-2.47,1.01l-24.43,14.11c-9.42,5.44-21.15,5.44-30.58,0c-9.42-5.44-15.3-15.59-15.3-26.5v-28.22c0-0.92-0.14-1.8-0.36-2.66c-0.05-0.18-0.09-0.36-0.14-0.54c-0.25-0.83-0.57-1.62-0.99-2.37c-0.06-0.11-0.14-0.2-0.2-0.32c-0.4-0.68-0.9-1.31-1.44-1.89c-0.09-0.09-0.18-0.2-0.27-0.29c-0.6-0.6-1.31-1.12-2.07-1.6c-0.06-0.05-0.11-0.11-0.2-0.16l-48.87-28.21c-3.51-2.03-7.81-2.03-11.32,0L59.28,290.6c-3.51,2.03-5.67,5.76-5.67,9.81v56.43c0,4.05,2.16,7.78,5.67,9.81l48.87,28.21c0,0,0.16,0.06,0.23,0.09c0.77,0.43,1.55,0.77,2.38,0.99c0.14,0.05,0.27,0.06,0.4,0.09c0.77,0.18,1.55,0.29,2.34,0.32c0.09,0,0.18,0.05,0.29,0.05c0.05,0,0.09,0,0.14,0c0.86,0,1.69-0.14,2.52-0.34c0.18-0.05,0.36-0.09,0.54-0.16c0.86-0.25,1.69-0.57,2.47-1.01l24.43-14.11c9.42-5.44,21.15-5.44,30.59,0c9.42,5.44,15.3,15.59,15.3,26.48v28.21c0,0.92,0.14,1.8,0.36,2.66c0.05,0.18,0.09,0.36,0.14,0.54c0.25,0.83,0.57,1.62,0.99,2.37c0.06,0.11,0.14,0.2,0.2,0.32c0.4,0.68,0.9,1.31,1.44,1.89c0.09,0.09,0.18,0.2,0.27,0.29c0.61,0.61,1.31,1.12,2.07,1.6c0.06,0.05,0.11,0.11,0.2,0.16l48.87,28.21c1.75,1.01,3.71,1.51,5.67,1.51c1.96,0,3.92-0.52,5.67-1.51l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81L304.5,369.22z'
fill='#1796E2'
/>
</svg>
)
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1302,6 +1321,21 @@ export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTasksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 527.1 500' xmlns='http://www.w3.org/2000/svg'>
<polygon
fill='#0066DA'
points='410.4,58.3 368.8,81.2 348.2,120.6 368.8,168.8 407.8,211 450,187.5 475.9,142.8 450,87.5'
/>
<path
fill='#2684FC'
d='M249.3,219.4l98.9-98.9c29.1,22.1,50.5,53.8,59.6,90.4L272.1,346.7c-12.2,12.2-32,12.2-44.2,0l-91.5-91.5 c-9.8-9.8-9.8-25.6,0-35.3l39-39c9.8-9.8,25.6-9.8,35.3,0L249.3,219.4z M519.8,63.6l-39.7-39.7c-9.7-9.7-25.6-9.7-35.3,0 l-34.4,34.4c27.5,23,49.9,51.8,65.5,84.5l43.9-43.9C529.6,89.2,529.6,73.3,519.8,63.6z M412.5,250c0,89.8-72.8,162.5-162.5,162.5 S87.5,339.8,87.5,250S160.2,87.5,250,87.5c36.9,0,70.9,12.3,98.2,33.1l62.2-62.2C367,21.9,311.1,0,250,0C111.9,0,0,111.9,0,250 s111.9,250,250,250s250-111.9,250-250c0-38.3-8.7-74.7-24.1-107.2L407.8,211C410.8,223.5,412.5,236.6,412.5,250z'
/>
</svg>
)
}
export function SupabaseIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradient0 = `supabase_paint0_${id}`
@@ -3430,6 +3464,23 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<path
d='M14.48 58.196L.558 34.082c-.744-1.288-.744-2.876 0-4.164L14.48 5.805c.743-1.287 2.115-2.08 3.6-2.082h27.857c1.48.007 2.845.8 3.585 2.082l13.92 24.113c.744 1.288.744 2.876 0 4.164L49.52 58.196c-.743 1.287-2.115 2.08-3.6 2.082H18.07c-1.483-.005-2.85-.798-3.593-2.082z'
fill='#4386fa'
/>
<path
d='M40.697 24.235s3.87 9.283-1.406 14.545-14.883 1.894-14.883 1.894L43.95 60.27h1.984c1.486-.002 2.858-.796 3.6-2.082L58.75 42.23z'
opacity='.1'
/>
<path
d='M45.267 43.23L41 38.953a.67.67 0 0 0-.158-.12 11.63 11.63 0 1 0-2.032 2.037.67.67 0 0 0 .113.15l4.277 4.277a.67.67 0 0 0 .947 0l1.12-1.12a.67.67 0 0 0 0-.947zM31.64 40.464a8.75 8.75 0 1 1 8.749-8.749 8.75 8.75 0 0 1-8.749 8.749zm-5.593-9.216v3.616c.557.983 1.363 1.803 2.338 2.375v-6.013zm4.375-2.998v9.772a6.45 6.45 0 0 0 2.338 0V28.25zm6.764 6.606v-2.142H34.85v4.5a6.43 6.43 0 0 0 2.338-2.368z'
fill='#fff'
/>
</svg>
)
export const GoogleVaultIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 82 82'>
<path
@@ -5445,6 +5496,34 @@ export function GoogleMapsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTranslateIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 998.1 998.3'>
<path
fill='#DBDBDB'
d='M931.7 998.3c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4H283.6l260.1 797.9h388z'
/>
<path
fill='#DCDCDC'
d='M931.7 230.4c9.7 0 18.9 3.8 25.8 10.6 6.8 6.7 10.6 15.5 10.6 24.8v667.1c0 9.3-3.7 18.1-10.6 24.8-6.9 6.8-16.1 10.6-25.8 10.6H565.5L324.9 230.4h606.8m0-30H283.6l260.1 797.9h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z'
/>
<polygon fill='#4352B8' points='482.3,809.8 543.7,998.3 714.4,809.8' />
<path
fill='#607988'
d='M936.1 476.1V437H747.6v-63.2h-61.2V437H566.1v39.1h239.4c-12.8 45.1-41.1 87.7-68.7 120.8-48.9-57.9-49.1-76.7-49.1-76.7h-50.8s2.1 28.2 70.7 108.6c-22.3 22.8-39.2 36.3-39.2 36.3l15.6 48.8s23.6-20.3 53.1-51.6c29.6 32.1 67.8 70.7 117.2 116.7l32.1-32.1c-52.9-48-91.7-86.1-120.2-116.7 38.2-45.2 77-102.1 85.2-154.2H936v.1z'
/>
<path
fill='#4285F4'
d='M66.4 0C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h648.1L454.4 0h-388z'
/>
<path
fill='#EEEEEE'
d='M371.4 430.6c-2.5 30.3-28.4 75.2-91.1 75.2-54.3 0-98.3-44.9-98.3-100.2s44-100.2 98.3-100.2c30.9 0 51.5 13.4 63.3 24.3l41.2-39.6c-27.1-25-62.4-40.6-104.5-40.6-86.1 0-156 69.9-156 156s69.9 156 156 156c90.2 0 149.8-63.3 149.8-152.6 0-12.8-1.6-22.2-3.7-31.8h-146v53.4l91 .1z'
/>
</svg>
)
}
export function DsPyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='30 28 185 175' fill='none'>

View File

@@ -25,6 +25,7 @@ import {
ConfluenceIcon,
CursorIcon,
DatadogIcon,
DevinIcon,
DiscordIcon,
DocumentIcon,
DropboxIcon,
@@ -42,6 +43,7 @@ import {
GitLabIcon,
GmailIcon,
GongIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
GoogleDocsIcon,
@@ -52,6 +54,8 @@ import {
GoogleMapsIcon,
GoogleSheetsIcon,
GoogleSlidesIcon,
GoogleTasksIcon,
GoogleTranslateIcon,
GoogleVaultIcon,
GrafanaIcon,
GrainIcon,
@@ -171,6 +175,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
datadog: DatadogIcon,
devin: DevinIcon,
discord: DiscordIcon,
dropbox: DropboxIcon,
dspy: DsPyIcon,
@@ -187,6 +192,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
gitlab: GitLabIcon,
gmail_v2: GmailIcon,
gong: GongIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,
google_docs: GoogleDocsIcon,
@@ -197,6 +203,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_search: GoogleIcon,
google_sheets_v2: GoogleSheetsIcon,
google_slides_v2: GoogleSlidesIcon,
google_tasks: GoogleTasksIcon,
google_translate: GoogleTranslateIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,

View File

@@ -97,6 +97,7 @@ Understanding these core principles will help you build better workflows:
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
5. **State Persistence**: All block outputs and execution details are preserved for debugging
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
## Next Steps

View File

@@ -326,6 +326,8 @@ Get details about a specific version of a Confluence page.
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `pageId` | string | ID of the page |
| `title` | string | Page title at this version |
| `content` | string | Page content with HTML tags stripped at this version |
| `version` | object | Detailed version information |
| ↳ `number` | number | Version number |
| ↳ `message` | string | Version message |
@@ -336,6 +338,9 @@ Get details about a specific version of a Confluence page.
| ↳ `collaborators` | array | List of collaborator account IDs for this version |
| ↳ `prevVersion` | number | Previous version number |
| ↳ `nextVersion` | number | Next version number |
| `body` | object | Raw page body content in storage format at this version |
| ↳ `value` | string | The content value in the specified format |
| ↳ `representation` | string | Content representation type |
### `confluence_list_page_properties`
@@ -1008,6 +1013,85 @@ Get details about a specific Confluence space.
| ↳ `value` | string | Description text content |
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
### `confluence_create_space`
Create a new Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `name` | string | Yes | Name for the new space |
| `key` | string | Yes | Unique key for the space \(uppercase, no spaces\) |
| `description` | string | No | Description for the new space |
| `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 |
| `spaceId` | string | Created space ID |
| `name` | string | Space name |
| `key` | string | Space key |
| `type` | string | Space type |
| `status` | string | Space status |
| `url` | string | URL to view the space |
| `homepageId` | string | Homepage ID |
| `description` | object | Space description |
| ↳ `value` | string | Description text content |
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
### `confluence_update_space`
Update a Confluence space name or description.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | ID of the space to update |
| `name` | string | No | New name for the space |
| `description` | string | No | New description for the space |
| `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 |
| `spaceId` | string | Updated space ID |
| `name` | string | Space name |
| `key` | string | Space key |
| `type` | string | Space type |
| `status` | string | Space status |
| `url` | string | URL to view the space |
| `description` | object | Space description |
| ↳ `value` | string | Description text content |
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
### `confluence_delete_space`
Delete a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | ID of the space 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 |
| `spaceId` | string | Deleted space ID |
| `deleted` | boolean | Deletion status |
### `confluence_list_spaces`
List all Confluence spaces accessible to the user.
@@ -1040,4 +1124,311 @@ List all Confluence spaces accessible to the user.
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_list_space_properties`
List properties on a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID to list properties for |
| `limit` | number | No | Maximum number of properties to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `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 |
| `properties` | array | Array of space properties |
| ↳ `id` | string | Property ID |
| ↳ `key` | string | Property key |
| ↳ `value` | json | Property value |
| `spaceId` | string | Space ID |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_create_space_property`
Create a property on a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID to create the property on |
| `key` | string | Yes | Property key/name |
| `value` | json | No | Property value \(JSON\) |
| `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 |
| `propertyId` | string | Created property ID |
| `key` | string | Property key |
| `value` | json | Property value |
| `spaceId` | string | Space ID |
### `confluence_delete_space_property`
Delete a property from a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID the property belongs to |
| `propertyId` | string | Yes | Property ID 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 |
| `spaceId` | string | Space ID |
| `propertyId` | string | Deleted property ID |
| `deleted` | boolean | Deletion status |
### `confluence_list_space_permissions`
List permissions for a Confluence space.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID to list permissions for |
| `limit` | number | No | Maximum number of permissions to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `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 |
| `permissions` | array | Array of space permissions |
| ↳ `id` | string | Permission ID |
| ↳ `principalType` | string | Principal type \(user, group, role\) |
| ↳ `principalId` | string | Principal ID |
| ↳ `operationKey` | string | Operation key \(read, create, delete, etc.\) |
| ↳ `operationTargetType` | string | Target type \(page, blogpost, space, etc.\) |
| ↳ `anonymousAccess` | boolean | Whether anonymous access is allowed |
| ↳ `unlicensedAccess` | boolean | Whether unlicensed access is allowed |
| `spaceId` | string | Space ID |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_get_page_descendants`
Get all descendants of a Confluence page recursively.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | Page ID to get descendants for |
| `limit` | number | No | Maximum number of descendants to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `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 |
| `descendants` | array | Array of descendant pages |
| ↳ `id` | string | Page ID |
| ↳ `title` | string | Page title |
| ↳ `type` | string | Content type \(page, whiteboard, database, etc.\) |
| ↳ `status` | string | Page status |
| ↳ `spaceId` | string | Space ID |
| ↳ `parentId` | string | Parent page ID |
| ↳ `childPosition` | number | Position among siblings |
| ↳ `depth` | number | Depth in the hierarchy |
| `pageId` | string | Parent page ID |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_list_tasks`
List inline tasks from Confluence. Optionally filter by page, space, assignee, or status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | No | Filter tasks by page ID |
| `spaceId` | string | No | Filter tasks by space ID |
| `assignedTo` | string | No | Filter tasks by assignee account ID |
| `status` | string | No | Filter tasks by status \(complete or incomplete\) |
| `limit` | number | No | Maximum number of tasks to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `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 |
| `tasks` | array | Array of Confluence tasks |
| ↳ `id` | string | Task ID |
| ↳ `localId` | string | Local task ID |
| ↳ `spaceId` | string | Space ID |
| ↳ `pageId` | string | Page ID |
| ↳ `blogPostId` | string | Blog post ID |
| ↳ `status` | string | Task status \(complete or incomplete\) |
| ↳ `body` | string | Task body content in storage format |
| ↳ `createdBy` | string | Creator account ID |
| ↳ `assignedTo` | string | Assignee account ID |
| ↳ `completedBy` | string | Completer account ID |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| ↳ `dueAt` | string | Due date |
| ↳ `completedAt` | string | Completion timestamp |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `confluence_get_task`
Get a specific Confluence inline task by ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `taskId` | string | Yes | The ID of the task to retrieve |
| `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 | Task ID |
| `localId` | string | Local task ID |
| `spaceId` | string | Space ID |
| `pageId` | string | Page ID |
| `blogPostId` | string | Blog post ID |
| `status` | string | Task status \(complete or incomplete\) |
| `body` | string | Task body content in storage format |
| `createdBy` | string | Creator account ID |
| `assignedTo` | string | Assignee account ID |
| `completedBy` | string | Completer account ID |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last update timestamp |
| `dueAt` | string | Due date |
| `completedAt` | string | Completion timestamp |
### `confluence_update_task`
Update the status of a Confluence inline task (complete or incomplete).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `taskId` | string | Yes | The ID of the task to update |
| `status` | string | Yes | New status for the task \(complete or incomplete\) |
| `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 | Task ID |
| `localId` | string | Local task ID |
| `spaceId` | string | Space ID |
| `pageId` | string | Page ID |
| `blogPostId` | string | Blog post ID |
| `status` | string | Updated task status |
| `body` | string | Task body content in storage format |
| `createdBy` | string | Creator account ID |
| `assignedTo` | string | Assignee account ID |
| `completedBy` | string | Completer account ID |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last update timestamp |
| `dueAt` | string | Due date |
| `completedAt` | string | Completion timestamp |
### `confluence_update_blogpost`
Update an existing Confluence blog post title and/or content.
#### 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 storage format |
| `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 |
| `blogPostId` | string | Updated blog post ID |
| `title` | string | Blog post title |
| `status` | string | Blog post status |
| `spaceId` | string | Space ID |
| `version` | json | Version information |
| `url` | string | URL to view the blog post |
### `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 |
| `blogPostId` | string | Deleted blog post ID |
| `deleted` | boolean | Deletion status |
### `confluence_get_user`
Get display name and profile info for a Confluence user by account ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `accountId` | string | Yes | The Atlassian account ID of the user to look up |
| `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 |
| `accountId` | string | Atlassian account ID of the user |
| `displayName` | string | Display name of the user |
| `email` | string | Email address of the user |
| `accountType` | string | Account type \(e.g., atlassian, app, customer\) |
| `profilePicture` | string | Path to the user profile picture |
| `publicName` | string | Public name of the user |

View File

@@ -0,0 +1,157 @@
---
title: Devin
description: Autonomous AI software engineer
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="devin"
color="#12141A"
/>
{/* MANUAL-CONTENT-START:intro */}
[Devin](https://devin.ai/) is an autonomous AI software engineer by Cognition that can independently write, run, debug, and deploy code.
With Devin, you can:
- **Automate coding tasks**: Assign software engineering tasks and let Devin autonomously write, test, and iterate on code
- **Manage sessions**: Create, monitor, and interact with Devin sessions to track progress on assigned tasks
- **Guide active work**: Send messages to running sessions to provide additional context, redirect efforts, or answer questions
- **Retrieve structured output**: Poll completed sessions for pull requests, structured results, and detailed status
- **Control costs**: Set ACU (Autonomous Compute Unit) limits to cap spending on long-running tasks
- **Standardize workflows**: Use playbook IDs to apply repeatable task patterns across sessions
In Sim, the Devin integration enables your agents to programmatically manage Devin sessions as part of their workflows:
- **Create sessions**: Kick off new Devin sessions with a prompt describing the task, optional playbook, ACU limits, and tags
- **Get session details**: Retrieve the full state of a session including status, pull requests, structured output, and resource consumption
- **List sessions**: Query all sessions in your organization with optional pagination
- **Send messages**: Communicate with active or suspended sessions to provide guidance, and automatically resume suspended sessions
This allows for powerful automation scenarios such as triggering code generation from upstream events, polling for completion before consuming results, orchestrating multi-step development pipelines, and integrating Devin's output into broader agent workflows.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Devin into your workflow. Create sessions to assign coding tasks, send messages to guide active sessions, and retrieve session status and results. Devin autonomously writes, runs, and tests code.
## Tools
### `devin_create_session`
Create a new Devin session with a prompt. Devin will autonomously work on the task described in the prompt.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `prompt` | string | Yes | The task prompt for Devin to work on |
| `playbookId` | string | No | Optional playbook ID to guide the session |
| `maxAcuLimit` | number | No | Maximum ACU limit for the session |
| `tags` | string | No | Comma-separated tags for the session |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessionId` | string | Unique identifier for the session |
| `url` | string | URL to view the session in the Devin UI |
| `status` | string | Session status \(new, claimed, running, exit, error, suspended, resuming\) |
| `statusDetail` | string | Detailed status \(working, waiting_for_user, waiting_for_approval, finished, inactivity, etc.\) |
| `title` | string | Session title |
| `createdAt` | number | Unix timestamp when the session was created |
| `updatedAt` | number | Unix timestamp when the session was last updated |
| `acusConsumed` | number | ACUs consumed by the session |
| `tags` | json | Tags associated with the session |
| `pullRequests` | json | Pull requests created during the session |
| `structuredOutput` | json | Structured output from the session |
| `playbookId` | string | Associated playbook ID |
### `devin_get_session`
Retrieve details of an existing Devin session including status, tags, pull requests, and structured output.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `sessionId` | string | Yes | The session ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessionId` | string | Unique identifier for the session |
| `url` | string | URL to view the session in the Devin UI |
| `status` | string | Session status \(new, claimed, running, exit, error, suspended, resuming\) |
| `statusDetail` | string | Detailed status \(working, waiting_for_user, waiting_for_approval, finished, inactivity, etc.\) |
| `title` | string | Session title |
| `createdAt` | number | Unix timestamp when the session was created |
| `updatedAt` | number | Unix timestamp when the session was last updated |
| `acusConsumed` | number | ACUs consumed by the session |
| `tags` | json | Tags associated with the session |
| `pullRequests` | json | Pull requests created during the session |
| `structuredOutput` | json | Structured output from the session |
| `playbookId` | string | Associated playbook ID |
### `devin_list_sessions`
List Devin sessions in the organization. Returns up to 100 sessions by default.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `limit` | number | No | Maximum number of sessions to return \(1-200, default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessions` | array | List of Devin sessions |
| ↳ `sessionId` | string | Unique identifier for the session |
| ↳ `url` | string | URL to view the session |
| ↳ `status` | string | Session status |
| ↳ `statusDetail` | string | Detailed status |
| ↳ `title` | string | Session title |
| ↳ `createdAt` | number | Creation timestamp \(Unix\) |
| ↳ `updatedAt` | number | Last updated timestamp \(Unix\) |
| ↳ `tags` | json | Session tags |
### `devin_send_message`
Send a message to a Devin session. If the session is suspended, it will be automatically resumed. Returns the updated session state.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Devin API key \(service user credential starting with cog_\) |
| `sessionId` | string | Yes | The session ID to send the message to |
| `message` | string | Yes | The message to send to Devin |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sessionId` | string | Unique identifier for the session |
| `url` | string | URL to view the session in the Devin UI |
| `status` | string | Session status \(new, claimed, running, exit, error, suspended, resuming\) |
| `statusDetail` | string | Detailed status \(working, waiting_for_user, waiting_for_approval, finished, inactivity, etc.\) |
| `title` | string | Session title |
| `createdAt` | number | Unix timestamp when the session was created |
| `updatedAt` | number | Unix timestamp when the session was last updated |
| `acusConsumed` | number | ACUs consumed by the session |
| `tags` | json | Tags associated with the session |
| `pullRequests` | json | Pull requests created during the session |
| `structuredOutput` | json | Structured output from the session |
| `playbookId` | string | Associated playbook ID |

View File

@@ -0,0 +1,168 @@
---
title: Google BigQuery
description: Query, list, and insert data in Google BigQuery
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_bigquery"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google BigQuery](https://cloud.google.com/bigquery) is Google Cloud's fully managed, serverless data warehouse designed for large-scale data analytics. BigQuery lets you run fast SQL queries on massive datasets, making it ideal for business intelligence, data exploration, and machine learning pipelines. It supports standard SQL, streaming inserts, and integrates with the broader Google Cloud ecosystem.
In Sim, the Google BigQuery integration allows your agents to query datasets, list tables, inspect schemas, and insert rows as part of automated workflows. This enables use cases such as automated reporting, data pipeline orchestration, real-time data ingestion, and analytics-driven decision making. By connecting Sim with BigQuery, your agents can pull insights from petabytes of data, write results back to tables, and keep your analytics workflows running without manual intervention.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to Google BigQuery to run SQL queries, list datasets and tables, get table metadata, and insert rows.
## Tools
### `google_bigquery_query`
Run a SQL query against Google BigQuery and return the results
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `query` | string | Yes | SQL query to execute |
| `useLegacySql` | boolean | No | Whether to use legacy SQL syntax \(default: false\) |
| `maxResults` | number | No | Maximum number of rows to return |
| `defaultDatasetId` | string | No | Default dataset for unqualified table names |
| `location` | string | No | Processing location \(e.g., "US", "EU"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `columns` | array | Array of column names from the query result |
| `rows` | array | Array of row objects keyed by column name |
| `totalRows` | string | Total number of rows in the complete result set |
| `jobComplete` | boolean | Whether the query completed within the timeout |
| `totalBytesProcessed` | string | Total bytes processed by the query |
| `cacheHit` | boolean | Whether the query result was served from cache |
| `jobReference` | object | Job reference \(useful when jobComplete is false\) |
| ↳ `projectId` | string | Project ID containing the job |
| ↳ `jobId` | string | Unique job identifier |
| ↳ `location` | string | Geographic location of the job |
| `pageToken` | string | Token for fetching additional result pages |
### `google_bigquery_list_datasets`
List all datasets in a Google BigQuery project
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `maxResults` | number | No | Maximum number of datasets to return |
| `pageToken` | string | No | Token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `datasets` | array | Array of dataset objects |
| ↳ `datasetId` | string | Unique dataset identifier |
| ↳ `projectId` | string | Project ID containing this dataset |
| ↳ `friendlyName` | string | Descriptive name for the dataset |
| ↳ `location` | string | Geographic location where the data resides |
| `nextPageToken` | string | Token for fetching next page of results |
### `google_bigquery_list_tables`
List all tables in a Google BigQuery dataset
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `datasetId` | string | Yes | BigQuery dataset ID |
| `maxResults` | number | No | Maximum number of tables to return |
| `pageToken` | string | No | Token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tables` | array | Array of table objects |
| ↳ `tableId` | string | Table identifier |
| ↳ `datasetId` | string | Dataset ID containing this table |
| ↳ `projectId` | string | Project ID containing this table |
| ↳ `type` | string | Table type \(TABLE, VIEW, EXTERNAL, etc.\) |
| ↳ `friendlyName` | string | User-friendly name for the table |
| ↳ `creationTime` | string | Time when created, in milliseconds since epoch |
| `totalItems` | number | Total number of tables in the dataset |
| `nextPageToken` | string | Token for fetching next page of results |
### `google_bigquery_get_table`
Get metadata and schema for a Google BigQuery table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `datasetId` | string | Yes | BigQuery dataset ID |
| `tableId` | string | Yes | BigQuery table ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableId` | string | Table ID |
| `datasetId` | string | Dataset ID |
| `projectId` | string | Project ID |
| `type` | string | Table type \(TABLE, VIEW, SNAPSHOT, MATERIALIZED_VIEW, EXTERNAL\) |
| `description` | string | Table description |
| `numRows` | string | Total number of rows |
| `numBytes` | string | Total size in bytes, excluding data in streaming buffer |
| `schema` | array | Array of column definitions |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Data type \(STRING, INTEGER, FLOAT, BOOLEAN, TIMESTAMP, RECORD, etc.\) |
| ↳ `mode` | string | Column mode \(NULLABLE, REQUIRED, or REPEATED\) |
| ↳ `description` | string | Column description |
| `creationTime` | string | Table creation time \(milliseconds since epoch\) |
| `lastModifiedTime` | string | Last modification time \(milliseconds since epoch\) |
| `location` | string | Geographic location where the table resides |
### `google_bigquery_insert_rows`
Insert rows into a Google BigQuery table using streaming insert
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Google Cloud project ID |
| `datasetId` | string | Yes | BigQuery dataset ID |
| `tableId` | string | Yes | BigQuery table ID |
| `rows` | string | Yes | JSON array of row objects to insert |
| `skipInvalidRows` | boolean | No | Whether to insert valid rows even if some are invalid |
| `ignoreUnknownValues` | boolean | No | Whether to ignore columns not in the table schema |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `insertedRows` | number | Number of rows successfully inserted |
| `errors` | array | Array of per-row insertion errors \(empty if all succeeded\) |
| ↳ `index` | number | Zero-based index of the row that failed |
| ↳ `errors` | array | Error details for this row |
| ↳ `reason` | string | Short error code summarizing the error |
| ↳ `location` | string | Where the error occurred |
| ↳ `message` | string | Human-readable error description |

View File

@@ -10,6 +10,13 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Books](https://books.google.com) is Google's comprehensive book discovery and metadata service, providing access to millions of books from publishers, libraries, and digitized collections worldwide. The Google Books API enables programmatic search and retrieval of detailed book information including titles, authors, descriptions, ratings, and publication details.
In Sim, the Google Books integration allows your agents to search for books and retrieve volume details as part of automated workflows. This enables use cases such as content research, reading list curation, bibliographic data enrichment, ISBN lookups, and knowledge gathering from published works. By connecting Sim with Google Books, your agents can discover and analyze book metadata, filter by availability or format, and incorporate literary references into their outputs—all without manual research.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.

View File

@@ -0,0 +1,205 @@
---
title: Google Tasks
description: Manage Google Tasks
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_tasks"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Tasks](https://support.google.com/tasks) is Google's lightweight task management service, integrated into Gmail, Google Calendar, and the standalone Google Tasks app. It provides a simple way to create, organize, and track to-do items with support for due dates, subtasks, and task lists. As part of Google Workspace, Google Tasks keeps your action items synchronized across all your devices.
In Sim, the Google Tasks integration allows your agents to create, read, update, delete, and list tasks and task lists as part of automated workflows. This enables use cases such as automated task creation from incoming data, to-do list management based on workflow triggers, task status tracking, and deadline monitoring. By connecting Sim with Google Tasks, your agents can manage action items programmatically, keep teams organized, and ensure nothing falls through the cracks.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Google Tasks into your workflow. Create, read, update, delete, and list tasks and task lists.
## Tools
### `google_tasks_create`
Create a new task in a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `title` | string | Yes | Title of the task \(max 1024 characters\) |
| `notes` | string | No | Notes/description for the task \(max 8192 characters\) |
| `due` | string | No | Due date in RFC 3339 format \(e.g., 2025-06-03T00:00:00.000Z\) |
| `status` | string | No | Task status: "needsAction" or "completed" |
| `parent` | string | No | Parent task ID to create this task as a subtask. Omit for top-level tasks. |
| `previous` | string | No | Previous sibling task ID to position after. Omit to place first among siblings. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Task ID |
| `title` | string | Task title |
| `notes` | string | Task notes |
| `status` | string | Task status \(needsAction or completed\) |
| `due` | string | Due date |
| `updated` | string | Last modification time |
| `selfLink` | string | URL for the task |
| `webViewLink` | string | Link to task in Google Tasks UI |
| `parent` | string | Parent task ID |
| `position` | string | Position among sibling tasks |
| `completed` | string | Completion date |
| `deleted` | boolean | Whether the task is deleted |
### `google_tasks_list`
List all tasks in a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `maxResults` | number | No | Maximum number of tasks to return \(default 20, max 100\) |
| `pageToken` | string | No | Token for pagination |
| `showCompleted` | boolean | No | Whether to show completed tasks \(default true\) |
| `showDeleted` | boolean | No | Whether to show deleted tasks \(default false\) |
| `showHidden` | boolean | No | Whether to show hidden tasks \(default false\) |
| `dueMin` | string | No | Lower bound for due date filter \(RFC 3339 timestamp\) |
| `dueMax` | string | No | Upper bound for due date filter \(RFC 3339 timestamp\) |
| `completedMin` | string | No | Lower bound for task completion date \(RFC 3339 timestamp\) |
| `completedMax` | string | No | Upper bound for task completion date \(RFC 3339 timestamp\) |
| `updatedMin` | string | No | Lower bound for last modification time \(RFC 3339 timestamp\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tasks` | array | List of tasks |
| ↳ `id` | string | Task identifier |
| ↳ `title` | string | Title of the task |
| ↳ `notes` | string | Notes/description for the task |
| ↳ `status` | string | Task status: "needsAction" or "completed" |
| ↳ `due` | string | Due date \(RFC 3339 timestamp\) |
| ↳ `completed` | string | Completion date \(RFC 3339 timestamp\) |
| ↳ `updated` | string | Last modification time \(RFC 3339 timestamp\) |
| ↳ `selfLink` | string | URL pointing to this task |
| ↳ `webViewLink` | string | Link to task in Google Tasks UI |
| ↳ `parent` | string | Parent task identifier |
| ↳ `position` | string | Position among sibling tasks \(string-based ordering\) |
| ↳ `hidden` | boolean | Whether the task is hidden |
| ↳ `deleted` | boolean | Whether the task is deleted |
| ↳ `links` | array | Collection of links associated with the task |
| ↳ `type` | string | Link type \(e.g., "email", "generic", "chat_message"\) |
| ↳ `description` | string | Link description |
| ↳ `link` | string | The URL |
| `nextPageToken` | string | Token for retrieving the next page of results |
### `google_tasks_get`
Retrieve a specific task by ID from a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `taskId` | string | Yes | The ID of the task to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Task ID |
| `title` | string | Task title |
| `notes` | string | Task notes |
| `status` | string | Task status \(needsAction or completed\) |
| `due` | string | Due date |
| `updated` | string | Last modification time |
| `selfLink` | string | URL for the task |
| `webViewLink` | string | Link to task in Google Tasks UI |
| `parent` | string | Parent task ID |
| `position` | string | Position among sibling tasks |
| `completed` | string | Completion date |
| `deleted` | boolean | Whether the task is deleted |
### `google_tasks_update`
Update an existing task in a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `taskId` | string | Yes | The ID of the task to update |
| `title` | string | No | New title for the task |
| `notes` | string | No | New notes for the task |
| `due` | string | No | New due date in RFC 3339 format |
| `status` | string | No | New status: "needsAction" or "completed" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Task ID |
| `title` | string | Task title |
| `notes` | string | Task notes |
| `status` | string | Task status \(needsAction or completed\) |
| `due` | string | Due date |
| `updated` | string | Last modification time |
| `selfLink` | string | URL for the task |
| `webViewLink` | string | Link to task in Google Tasks UI |
| `parent` | string | Parent task ID |
| `position` | string | Position among sibling tasks |
| `completed` | string | Completion date |
| `deleted` | boolean | Whether the task is deleted |
### `google_tasks_delete`
Delete a task from a Google Tasks list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskListId` | string | No | Task list ID \(defaults to primary task list "@default"\) |
| `taskId` | string | Yes | The ID of the task to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Deleted task ID |
| `deleted` | boolean | Whether deletion was successful |
### `google_tasks_list_task_lists`
Retrieve all task lists for the authenticated user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `maxResults` | number | No | Maximum number of task lists to return \(default 20, max 100\) |
| `pageToken` | string | No | Token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskLists` | array | List of task lists |
| ↳ `id` | string | Task list identifier |
| ↳ `title` | string | Title of the task list |
| ↳ `updated` | string | Last modification time \(RFC 3339 timestamp\) |
| ↳ `selfLink` | string | URL pointing to this task list |
| `nextPageToken` | string | Token for retrieving the next page of results |

View File

@@ -0,0 +1,60 @@
---
title: Google Translate
description: Translate text using Google Cloud Translation
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_translate"
color="#E0E0E0"
/>
## Usage Instructions
Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.
## Tools
### `google_translate_text`
Translate text between languages using the Google Cloud Translation API. Supports auto-detection of the source language.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to translate |
| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) |
| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. |
| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `translatedText` | string | The translated text |
| `detectedSourceLanguage` | string | The detected source language code \(if source was not specified\) |
### `google_translate_detect`
Detect the language of text using the Google Cloud Translation API.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to detect the language of |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `language` | string | The detected language code \(e.g., "en", "es", "fr"\) |
| `confidence` | number | Confidence score of the detection |

View File

@@ -21,6 +21,7 @@
"confluence",
"cursor",
"datadog",
"devin",
"discord",
"dropbox",
"dspy",
@@ -37,6 +38,7 @@
"gitlab",
"gmail",
"gong",
"google_bigquery",
"google_books",
"google_calendar",
"google_docs",
@@ -47,6 +49,8 @@
"google_search",
"google_sheets",
"google_slides",
"google_tasks",
"google_translate",
"google_vault",
"grafana",
"grain",

View File

@@ -5,11 +5,12 @@ description: User-defined data tables for storing and querying structured data
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
<BlockInfoCard
type="table"
color="#10B981"
/>
{/* MANUAL-CONTENT-START:intro */}
Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations.
**Why Use Tables?**
@@ -26,6 +27,7 @@ Tables allow you to create and manage custom data tables directly within Sim. St
- Batch operations for bulk inserts
- Bulk updates and deletes by filter
- Up to 10,000 rows per table, 100 tables per workspace
{/* MANUAL-CONTENT-END */}
## Creating Tables

View File

@@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
@@ -178,8 +179,14 @@ export const POST = withMcpAuth('read')(
'sync'
)
const simViaHeader = request.headers.get(SIM_VIA_HEADER)
const extraHeaders: Record<string, string> = {}
if (simViaHeader) {
extraHeaders[SIM_VIA_HEADER] = simViaHeader
}
const result = await Promise.race([
mcpService.executeTool(userId, serverId, toolCall, workspaceId),
mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
),

View File

@@ -283,3 +283,165 @@ 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 { domain, accessToken, blogPostId, title, content, cloudId: providedCloudId } = body
if (!domain || !accessToken || !blogPostId) {
return NextResponse.json(
{ error: 'Domain, access token, and blog post ID are required' },
{ status: 400 }
)
}
const blogPostIdValidation = validateAlphanumericId(blogPostId, 'blogPostId', 255)
if (!blogPostIdValidation.isValid) {
return NextResponse.json({ error: blogPostIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Fetch current blog post to get version number
const currentUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}?body-format=storage`
const currentResponse = await fetch(currentUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentResponse.ok) {
throw new Error(`Failed to fetch current blog post: ${currentResponse.status}`)
}
const currentPost = await currentResponse.json()
if (!currentPost.version?.number) {
return NextResponse.json(
{ error: 'Unable to determine current blog post version' },
{ status: 422 }
)
}
const currentVersion = currentPost.version.number
const updateBody: Record<string, unknown> = {
id: blogPostId,
version: { number: currentVersion + 1 },
status: 'current',
title: title || currentPost.title,
body: {
representation: 'storage',
value: content || currentPost.body?.storage?.value || '',
},
}
const response = await fetch(currentUrl, {
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(data)
} 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 { domain, accessToken, blogPostId, cloudId: providedCloudId } = body
if (!domain || !accessToken || !blogPostId) {
return NextResponse.json(
{ error: 'Domain, access token, and blog post ID are required' },
{ status: 400 }
)
}
const blogPostIdValidation = validateAlphanumericId(blogPostId, 'blogPostId', 255)
if (!blogPostIdValidation.isValid) {
return NextResponse.json({ error: blogPostIdValidation.error }, { status: 400 })
}
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,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageDescendantsAPI')
export const dynamic = 'force-dynamic'
/**
* Get all descendants of a Confluence page recursively.
* Uses GET /wiki/api/v2/pages/{id}/descendants
*/
export async function POST(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 { domain, accessToken, pageId, cloudId: providedCloudId, limit = 50, cursor } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/descendants?${queryParams.toString()}`
logger.info(`Fetching descendants for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
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 get page descendants (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const descendants = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
type: page.type ?? null,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
parentId: page.parentId ?? null,
childPosition: page.childPosition ?? null,
depth: page.depth ?? null,
}))
return NextResponse.json({
descendants,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error getting page descendants:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -1,8 +1,13 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import {
validateAlphanumericId,
validateJiraCloudId,
validateNumericId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluencePageVersionsAPI')
@@ -55,42 +60,85 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// If versionNumber is provided, get specific version
// If versionNumber is provided, get specific version with page content
if (versionNumber !== undefined && versionNumber !== null) {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}`
const versionValidation = validateNumericId(versionNumber, 'versionNumber', { min: 1 })
if (!versionValidation.isValid) {
return NextResponse.json({ error: versionValidation.error }, { status: 400 })
}
const safeVersion = versionValidation.sanitized
const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${safeVersion}`
const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${safeVersion}&body-format=storage`
logger.info(`Fetching version ${versionNumber} for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
const [versionResponse, pageResponse] = await Promise.all([
fetch(versionUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
fetch(pageUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
])
if (!response.ok) {
const errorData = await response.json().catch(() => null)
if (!versionResponse.ok) {
const errorData = await versionResponse.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
status: versionResponse.status,
statusText: versionResponse.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to get page version (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
const errorMessage =
errorData?.message || `Failed to get page version (${versionResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: versionResponse.status })
}
const data = await response.json()
const versionData = await versionResponse.json()
let title: string | null = null
let content: string | null = null
let body: Record<string, unknown> | null = null
if (pageResponse.ok) {
const pageData = await pageResponse.json()
title = pageData.title ?? null
body = pageData.body ?? null
const rawContent =
pageData.body?.storage?.value ||
pageData.body?.view?.value ||
pageData.body?.atlas_doc_format?.value ||
''
if (rawContent) {
content = cleanHtmlContent(rawContent)
}
} else {
logger.warn(
`Could not fetch page content for version ${versionNumber}: ${pageResponse.status}`
)
}
return NextResponse.json({
version: {
number: data.number,
message: data.message ?? null,
minorEdit: data.minorEdit ?? false,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
number: versionData.number,
message: versionData.message ?? null,
minorEdit: versionData.minorEdit ?? false,
authorId: versionData.authorId ?? null,
createdAt: versionData.createdAt ?? null,
},
pageId,
title,
content,
body,
})
}
// List all versions
@@ -98,6 +146,10 @@ export async function POST(request: NextRequest) {
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}

View File

@@ -185,7 +185,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const currentPageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}`
const currentPageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?body-format=storage`
const currentPageResponse = await fetch(currentPageUrl, {
headers: {
Accept: 'application/json',

View File

@@ -0,0 +1,114 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSpacePermissionsAPI')
export const dynamic = 'force-dynamic'
/**
* List permissions for a Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/permissions
*/
export async function POST(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 { domain, accessToken, spaceId, cloudId: providedCloudId, limit = 50, cursor } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/permissions?${queryParams.toString()}`
logger.info(`Fetching permissions for space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
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 list space permissions (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const permissions = (data.results || []).map((perm: any) => ({
id: perm.id,
principalType: perm.principal?.type ?? null,
principalId: perm.principal?.id ?? null,
operationKey: perm.operation?.key ?? null,
operationTargetType: perm.operation?.targetType ?? null,
anonymousAccess: perm.anonymousAccess ?? false,
unlicensedAccess: perm.unlicensedAccess ?? false,
}))
return NextResponse.json({
permissions,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing space permissions:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,209 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSpacePropertiesAPI')
export const dynamic = 'force-dynamic'
/**
* List, create, or delete space properties.
* Uses GET/POST /wiki/api/v2/spaces/{id}/properties
* and DELETE /wiki/api/v2/spaces/{id}/properties/{propertyId}
*/
export async function POST(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 {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
action,
key,
value,
propertyId,
limit = 50,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/properties`
// Validate required params for specific actions
if (action === 'delete' && !propertyId) {
return NextResponse.json(
{ error: 'Property ID is required for delete action' },
{ status: 400 }
)
}
if (action === 'create' && !key) {
return NextResponse.json(
{ error: 'Property key is required for create action' },
{ status: 400 }
)
}
// Delete a property
if (action === 'delete' && propertyId) {
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/${encodeURIComponent(propertyId)}`
logger.info(`Deleting space property ${propertyId} from space ${spaceId}`)
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 space property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ spaceId, propertyId, deleted: true })
}
// Create a property
if (action === 'create' && key) {
logger.info(`Creating space property '${key}' on space ${spaceId}`)
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ key, value: value ?? {} }),
})
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 create space property (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
propertyId: data.id,
key: data.key,
value: data.value ?? null,
spaceId,
})
}
// List properties
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `${baseUrl}?${queryParams.toString()}`
logger.info(`Fetching properties for space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
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 list space properties (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const properties = (data.results || []).map((prop: any) => ({
id: prop.id,
key: prop.key,
value: prop.value ?? null,
}))
return NextResponse.json({
properties,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with space properties:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -78,3 +78,258 @@ export async function GET(request: NextRequest) {
)
}
}
/**
* Create a new Confluence space.
* Uses POST /wiki/api/v2/spaces
*/
export async function POST(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 { domain, accessToken, name, key, description, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!name) {
return NextResponse.json({ error: 'Space name is required' }, { status: 400 })
}
if (!key) {
return NextResponse.json({ error: 'Space key is required' }, { status: 400 })
}
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/spaces`
const createBody: Record<string, unknown> = { name, key }
if (description) {
createBody.description = { value: description, representation: 'plain' }
}
logger.info(`Creating space with key ${key}`)
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(createBody),
})
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 create space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error creating Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Update a Confluence space.
* Uses PUT /wiki/api/v2/spaces/{id}
*/
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 { domain, accessToken, spaceId, name, description, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
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/spaces/${spaceId}`
if (!name && description === undefined) {
return NextResponse.json(
{ error: 'At least one of name or description is required for update' },
{ status: 400 }
)
}
const updateBody: Record<string, unknown> = {}
if (name) {
updateBody.name = name
} else {
const currentResponse = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentResponse.ok) {
return NextResponse.json(
{ error: `Failed to fetch current space: ${currentResponse.status}` },
{ status: currentResponse.status }
)
}
const currentSpace = await currentResponse.json()
updateBody.name = currentSpace.name
}
if (description !== undefined) {
updateBody.description = { value: description, representation: 'plain' }
}
logger.info(`Updating space ${spaceId}`)
const response = await fetch(url, {
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 space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error updating Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Delete a Confluence space.
* Uses DELETE /wiki/api/v2/spaces/{id}
*/
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 { domain, accessToken, spaceId, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
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/spaces/${spaceId}`
logger.info(`Deleting space ${spaceId}`)
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 space (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ spaceId, deleted: true })
} catch (error) {
logger.error('Error deleting Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,278 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
validatePathSegment,
} from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceTasksAPI')
export const dynamic = 'force-dynamic'
/**
* List, get, or update Confluence inline tasks.
* Uses GET /wiki/api/v2/tasks, GET /wiki/api/v2/tasks/{id}, PUT /wiki/api/v2/tasks/{id}
*/
export async function POST(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 {
domain,
accessToken,
cloudId: providedCloudId,
action,
taskId,
status: taskStatus,
pageId,
spaceId,
assignedTo,
limit = 50,
cursor,
} = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Update a task
if (action === 'update' && taskId) {
const taskIdValidation = validateAlphanumericId(taskId, 'taskId', 255)
if (!taskIdValidation.isValid) {
return NextResponse.json({ error: taskIdValidation.error }, { status: 400 })
}
// First fetch the current task to get required fields
const getUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
const getResponse = await fetch(getUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!getResponse.ok) {
const errorData = await getResponse.json().catch(() => null)
const errorMessage = errorData?.message || `Failed to fetch task (${getResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: getResponse.status })
}
const currentTask = await getResponse.json()
const updateBody: Record<string, unknown> = {
id: taskId,
status: taskStatus || currentTask.status,
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
logger.info(`Updating task ${taskId}`)
const response = await fetch(url, {
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 task (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
task: {
id: data.id,
localId: data.localId ?? null,
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status,
body: data.body?.storage?.value ?? 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,
},
})
}
// Get a specific task
if (taskId) {
const taskIdValidation = validateAlphanumericId(taskId, 'taskId', 255)
if (!taskIdValidation.isValid) {
return NextResponse.json({ error: taskIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
logger.info(`Fetching task ${taskId}`)
const response = await fetch(url, {
method: 'GET',
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 get task (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json({
task: {
id: data.id,
localId: data.localId ?? null,
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status,
body: data.body?.storage?.value ?? 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,
},
})
}
// List tasks
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
if (taskStatus) queryParams.append('status', taskStatus)
if (pageId) {
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
queryParams.append('page-id', pageId)
}
if (spaceId) {
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
queryParams.append('space-id', spaceId)
}
if (assignedTo) {
// Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134
const assignedToValidation = validatePathSegment(assignedTo, {
paramName: 'assignedTo',
maxLength: 128,
customPattern: /^[a-zA-Z0-9_|:-]+$/,
})
if (!assignedToValidation.isValid) {
return NextResponse.json({ error: assignedToValidation.error }, { status: 400 })
}
queryParams.append('assigned-to', assignedTo)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks?${queryParams.toString()}`
logger.info('Fetching tasks')
const response = await fetch(url, {
method: 'GET',
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 list tasks (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const tasks = (data.results || []).map((task: any) => ({
id: task.id,
localId: task.localId ?? null,
spaceId: task.spaceId ?? null,
pageId: task.pageId ?? null,
blogPostId: task.blogPostId ?? null,
status: task.status,
body: task.body?.storage?.value ?? null,
createdBy: task.createdBy ?? null,
assignedTo: task.assignedTo ?? null,
completedBy: task.completedBy ?? null,
createdAt: task.createdAt ?? null,
updatedAt: task.updatedAt ?? null,
dueAt: task.dueAt ?? null,
completedAt: task.completedAt ?? null,
}))
return NextResponse.json({
tasks,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with tasks:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,85 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validatePathSegment } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceUserAPI')
export const dynamic = 'force-dynamic'
/**
* Get a Confluence user by account ID.
* Uses GET /wiki/rest/api/user?accountId={accountId}
*/
export async function POST(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 { domain, accessToken, accountId, cloudId: providedCloudId } = body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!accountId) {
return NextResponse.json({ error: 'Account ID is required' }, { status: 400 })
}
// Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134
const accountIdValidation = validatePathSegment(accountId, {
paramName: 'accountId',
maxLength: 128,
customPattern: /^[a-zA-Z0-9_|:-]+$/,
})
if (!accountIdValidation.isValid) {
return NextResponse.json({ error: accountIdValidation.error }, { status: 400 })
}
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/rest/api/user?accountId=${encodeURIComponent(accountId)}`
const response = await fetch(url, {
method: 'GET',
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 get Confluence user (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error getting Confluence user:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,44 @@
/**
* GET /api/v1/admin/audit-logs/[id]
*
* Get a single audit log entry by ID.
*
* Response: AdminSingleResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminAuditLog } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id } = await context.params
try {
const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1)
if (!log) {
return notFoundResponse('AuditLog')
}
logger.info(`Admin API: Retrieved audit log ${id}`)
return singleResponse(toAdminAuditLog(log))
} catch (error) {
logger.error('Admin API: Failed to get audit log', { error, id })
return internalErrorResponse('Failed to get audit log')
}
})

View File

@@ -0,0 +1,96 @@
/**
* GET /api/v1/admin/audit-logs
*
* List all audit logs with pagination and filtering.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID
* - actorEmail: string (optional) - Filter by actor email
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
*
* Response: AdminListResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminAuditLog,
createPaginationMeta,
parsePaginationParams,
toAdminAuditLog,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const actionFilter = url.searchParams.get('action')
const resourceTypeFilter = url.searchParams.get('resourceType')
const resourceIdFilter = url.searchParams.get('resourceId')
const workspaceIdFilter = url.searchParams.get('workspaceId')
const actorIdFilter = url.searchParams.get('actorId')
const actorEmailFilter = url.searchParams.get('actorEmail')
const startDateFilter = url.searchParams.get('startDate')
const endDateFilter = url.searchParams.get('endDate')
if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) {
return badRequestResponse('Invalid startDate format. Use ISO 8601.')
}
if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) {
return badRequestResponse('Invalid endDate format. Use ISO 8601.')
}
try {
const conditions: SQL<unknown>[] = []
if (actionFilter) conditions.push(eq(auditLog.action, actionFilter))
if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter))
if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter))
if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter))
if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter))
if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter))
if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter)))
if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter)))
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [countResult, logs] = await Promise.all([
db.select({ total: count() }).from(auditLog).where(whereClause),
db
.select()
.from(auditLog)
.where(whereClause)
.orderBy(desc(auditLog.createdAt))
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminAuditLog[] = logs.map(toAdminAuditLog)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list audit logs', { error })
return internalErrorResponse('Failed to list audit logs')
}
})

View File

@@ -6,6 +6,7 @@
*/
import type {
auditLog,
member,
organization,
referralCampaigns,
@@ -694,3 +695,45 @@ export function toAdminReferralCampaign(
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}
// =============================================================================
// Audit Log Types
// =============================================================================
export type DbAuditLog = InferSelectModel<typeof auditLog>
export interface AdminAuditLog {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
ipAddress: string | null
userAgent: string | null
createdAt: string
}
export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog {
return {
id: dbLog.id,
workspaceId: dbLog.workspaceId,
actorId: dbLog.actorId,
actorName: dbLog.actorName,
actorEmail: dbLog.actorEmail,
action: dbLog.action,
resourceType: dbLog.resourceType,
resourceId: dbLog.resourceId,
resourceName: dbLog.resourceName,
description: dbLog.description,
metadata: dbLog.metadata,
ipAddress: dbLog.ipAddress,
userAgent: dbLog.userAgent,
createdAt: dbLog.createdAt.toISOString(),
}
}

View File

@@ -0,0 +1,78 @@
/**
* GET /api/v1/audit-logs/[id]
*
* Get a single audit log entry by ID, scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Scope includes logs from current org members AND logs within org workspaces
* (including those from departed members or system actions with null actorId).
*
* Response: { data: AuditLogEntry, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogDetailAPI')
export const revalidate = 0
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { id } = await params
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { orgMemberIds } = authResult.context
const orgWorkspaceIds = db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.ownerId, orgMemberIds))
const [log] = await db
.select()
.from(auditLog)
.where(
and(
eq(auditLog.id, id),
or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)
)
)
.limit(1)
if (!log) {
return NextResponse.json({ error: 'Audit log not found' }, { status: 404 })
}
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
/**
* Enterprise audit log authorization.
*
* Validates that the authenticated user is an admin/owner of an enterprise organization
* and returns the organization context needed for scoped queries.
*/
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
const logger = createLogger('V1AuditLogsAuth')
export interface EnterpriseAuditContext {
organizationId: string
orgMemberIds: string[]
}
type AuthResult =
| { success: true; context: EnterpriseAuditContext }
| { success: false; response: NextResponse }
/**
* Validates enterprise audit log access for the given user.
*
* Checks:
* 1. User belongs to an organization
* 2. User has admin or owner role
* 3. Organization has an active enterprise subscription
*
* Returns the organization ID and all member user IDs on success,
* or an error response on failure.
*/
export async function validateEnterpriseAuditAccess(userId: string): Promise<AuthResult> {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!membership) {
return {
success: false,
response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
}
}
if (membership.role !== 'admin' && membership.role !== 'owner') {
return {
success: false,
response: NextResponse.json(
{ error: 'Organization admin or owner role required' },
{ status: 403 }
),
}
}
const [orgSub, orgMembers] = await Promise.all([
db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, membership.organizationId),
eq(subscription.plan, 'enterprise'),
eq(subscription.status, 'active')
)
)
.limit(1),
db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, membership.organizationId)),
])
if (orgSub.length === 0) {
return {
success: false,
response: NextResponse.json(
{ error: 'Active enterprise subscription required' },
{ status: 403 }
),
}
}
const orgMemberIds = orgMembers.map((m) => m.userId)
logger.info('Enterprise audit access validated', {
userId,
organizationId: membership.organizationId,
memberCount: orgMemberIds.length,
})
return {
success: true,
context: {
organizationId: membership.organizationId,
orgMemberIds,
},
}
}

View File

@@ -0,0 +1,43 @@
/**
* Enterprise audit log response formatting.
*
* Defines the shape returned by the enterprise audit log API.
* Excludes `ipAddress` and `userAgent` for privacy.
*/
import type { auditLog } from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
type DbAuditLog = InferSelectModel<typeof auditLog>
export interface EnterpriseAuditLogEntry {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
createdAt: string
}
export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry {
return {
id: log.id,
workspaceId: log.workspaceId,
actorId: log.actorId,
actorName: log.actorName,
actorEmail: log.actorEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
resourceName: log.resourceName,
description: log.description,
metadata: log.metadata,
createdAt: log.createdAt.toISOString(),
}
}

View File

@@ -0,0 +1,191 @@
/**
* GET /api/v1/audit-logs
*
* List audit logs scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Query Parameters:
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID (must be an org member)
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
* - includeDeparted: boolean (optional, default: false) - Include logs from departed members
* - limit: number (optional, default: 50, max: 100)
* - cursor: string (optional) - Opaque cursor for pagination
*
* Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, gte, inArray, lt, lte, or, type SQL } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), {
message: 'Invalid date format. Use ISO 8601.',
})
const QueryParamsSchema = z.object({
action: z.string().optional(),
resourceType: z.string().optional(),
resourceId: z.string().optional(),
workspaceId: z.string().optional(),
actorId: z.string().optional(),
startDate: isoDateString.optional(),
endDate: isoDateString.optional(),
includeDeparted: z
.enum(['true', 'false'])
.transform((val) => val === 'true')
.optional()
.default('false'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
interface CursorData {
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { orgMemberIds } = authResult.context
const { searchParams } = new URL(request.url)
const rawParams = Object.fromEntries(searchParams.entries())
const validationResult = QueryParamsSchema.safeParse(rawParams)
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: validationResult.error.errors },
{ status: 400 }
)
}
const params = validationResult.data
if (params.actorId && !orgMemberIds.includes(params.actorId)) {
return NextResponse.json(
{ error: 'actorId is not a member of your organization' },
{ status: 400 }
)
}
let scopeCondition: SQL<unknown>
if (params.includeDeparted) {
const orgWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.ownerId, orgMemberIds))
const orgWorkspaceIds = orgWorkspaces.map((w) => w.id)
if (orgWorkspaceIds.length > 0) {
scopeCondition = or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)!
} else {
scopeCondition = inArray(auditLog.actorId, orgMemberIds)
}
} else {
scopeCondition = inArray(auditLog.actorId, orgMemberIds)
}
const conditions: SQL<unknown>[] = [scopeCondition]
if (params.action) conditions.push(eq(auditLog.action, params.action))
if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType))
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate)))
if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate)))
if (params.cursor) {
const cursorData = decodeCursor(params.cursor)
if (cursorData?.createdAt && cursorData.id) {
const cursorDate = new Date(cursorData.createdAt)
if (!Number.isNaN(cursorDate.getTime())) {
conditions.push(
or(
lt(auditLog.createdAt, cursorDate),
and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id))
)!
)
}
}
}
const rows = await db
.select()
.from(auditLog)
.where(and(...conditions))
.orderBy(desc(auditLog.createdAt), desc(auditLog.id))
.limit(params.limit + 1)
const hasMore = rows.length > params.limit
const data = rows.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const last = data[data.length - 1]
nextCursor = encodeCursor({
createdAt: last.createdAt.toISOString(),
id: last.id,
})
}
const formattedLogs = data.map(formatAuditLogEntry)
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Audit logs fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -19,7 +19,7 @@ export interface RateLimitResult {
export async function checkRateLimit(
request: NextRequest,
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -987,7 +987,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const onChildWorkflowInstanceReady = (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
iterationContext?: IterationContext,
executionOrder?: number
) => {
sendEvent({
type: 'block:childWorkflowStarted',
@@ -1001,6 +1002,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
iterationCurrent: iterationContext.iterationCurrent,
iterationContainerId: iterationContext.iterationContainerId,
}),
...(executionOrder !== undefined && { executionOrder }),
},
})
}

View File

@@ -21,6 +21,7 @@ interface TemplateCardProps {
blocks?: string[]
className?: string
state?: WorkflowState
description?: string | null
isStarred?: boolean
isVerified?: boolean
}
@@ -124,6 +125,7 @@ function TemplateCardInner({
blocks = [],
className,
state,
description,
isStarred = false,
isVerified = false,
}: TemplateCardProps) {
@@ -270,6 +272,12 @@ function TemplateCardInner({
</div>
</div>
{description && (
<p className='mt-[4px] truncate pl-[2px] text-[12px] text-[var(--text-tertiary)]'>
{description}
</p>
)}
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-[8px]'>
{authorImageUrl ? (

View File

@@ -196,6 +196,7 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}

View File

@@ -18,6 +18,7 @@ interface TemplateCardProps {
blocks?: string[]
className?: string
state?: WorkflowState
description?: string | null
isStarred?: boolean
isVerified?: boolean
}
@@ -127,6 +128,7 @@ function TemplateCardInner({
blocks = [],
className,
state,
description,
isStarred = false,
isVerified = false,
}: TemplateCardProps) {
@@ -277,6 +279,12 @@ function TemplateCardInner({
</div>
</div>
{description && (
<p className='mt-[4px] truncate pl-[2px] text-[12px] text-[var(--text-tertiary)]'>
{description}
</p>
)}
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
{authorImageUrl ? (

View File

@@ -222,6 +222,7 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}

View File

@@ -26,16 +26,21 @@ export interface CanvasMenuProps {
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onToggleWorkflowLock?: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
disableEdit?: boolean
disableAdmin?: boolean
canAdmin?: boolean
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
/** Whether all blocks in the workflow are locked */
allBlocksLocked?: boolean
/** Whether the workflow has any blocks */
hasBlocks?: boolean
}
/**
@@ -56,13 +61,17 @@ export function CanvasMenu({
onOpenLogs,
onToggleVariables,
onToggleChat,
onToggleWorkflowLock,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
disableEdit = false,
canAdmin = false,
canUndo = false,
canRedo = false,
hasLockedBlocks = false,
allBlocksLocked = false,
hasBlocks = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -142,6 +151,17 @@ export function CanvasMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
{canAdmin && onToggleWorkflowLock && (
<PopoverItem
disabled={!hasBlocks}
onClick={() => {
onToggleWorkflowLock()
onClose()
}}
>
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
<PopoverItem
onClick={() => {
onFitToView()

View File

@@ -61,6 +61,9 @@ export const Notifications = memo(function Notifications() {
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { notificationId, actionType: action.type })
}
@@ -175,7 +178,9 @@ export const Notifications = memo(function Notifications() {
? 'Fix in Copilot'
: notification.action!.type === 'refresh'
? 'Refresh'
: 'Take action'}
: notification.action!.type === 'unlock-workflow'
? 'Unlock Workflow'
: 'Take action'}
</Button>
)}
</div>

View File

@@ -40,10 +40,12 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files',
'https://www.googleapis.com/auth/drive': 'Access all Google Drive files',
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks',
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery',
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups',
@@ -81,6 +83,15 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'write:content.property:confluence': 'Create and manage content properties',
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
'read:user:confluence': 'View Confluence user profiles',
'read:task:confluence': 'View Confluence inline tasks',
'write:task:confluence': 'Update Confluence inline tasks',
'delete:blogpost:confluence': 'Delete Confluence blog posts',
'write:space:confluence': 'Create and update Confluence spaces',
'delete:space:confluence': 'Delete Confluence spaces',
'read:space.property:confluence': 'View Confluence space properties',
'write:space.property:confluence': 'Create and manage space properties',
'read:space.permission:confluence': 'View Confluence space permissions',
'read:me': 'Read profile information',
'database.read': 'Read database',
'database.write': 'Write to database',

View File

@@ -379,7 +379,7 @@ export function CredentialSelector({
filterOptions={true}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
className={overlayContent ? 'pl-[28px]' : ''}
/>
{needsUpdate && (

View File

@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowUp, Square } from 'lucide-react'
import { ArrowUp, Lock, Square, Unlock } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
@@ -41,8 +41,11 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
@@ -126,6 +129,15 @@ export const Panel = memo(function Panel() {
Object.values(state.blocks).some((block) => block.locked)
)
const allBlocksLocked = useWorkflowStore((state) => {
const blockList = Object.values(state.blocks)
return blockList.length > 0 && blockList.every((block) => block.locked)
})
const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)
const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
@@ -192,6 +204,7 @@ export const Panel = memo(function Panel() {
)
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
const { isSnapshotView } = useCurrentWorkflow()
/**
* Mark hydration as complete on mount
@@ -329,6 +342,17 @@ export const Panel = memo(function Panel() {
workspaceId,
])
/**
* Toggles the locked state of all blocks in the workflow
*/
const handleToggleWorkflowLock = useCallback(() => {
const blocks = useWorkflowStore.getState().blocks
const allLocked = Object.values(blocks).every((b) => b.locked)
const ids = getWorkflowLockToggleIds(blocks, !allLocked)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
setIsMenuOpen(false)
}, [collaborativeBatchToggleLocked])
// Compute run button state
const canRun = userPermissions.canRead // Running only requires read permissions
const isLoadingPermissions = userPermissions.isLoading
@@ -399,6 +423,16 @@ export const Panel = memo(function Panel() {
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>
</PopoverItem>
{userPermissions.canAdmin && !isSnapshotView && (
<PopoverItem onClick={handleToggleWorkflowLock} disabled={!hasBlocks}>
{allBlocksLocked ? (
<Unlock className='h-3 w-3' />
) : (
<Lock className='h-3 w-3' />
)}
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<VariableIcon className='h-3 w-3' />

View File

@@ -160,12 +160,16 @@ const IterationNodeRow = memo(function IterationNodeRow({
onSelectEntry,
isExpanded,
onToggle,
expandedNodes,
onToggleNode,
}: {
node: EntryNode
selectedEntryId: string | null
onSelectEntry: (entry: ConsoleEntry) => void
isExpanded: boolean
onToggle: () => void
expandedNodes: Set<string>
onToggleNode: (nodeId: string) => void
}) {
const { entry, children, iterationInfo } = node
const hasError = Boolean(entry.error) || children.some((c) => c.entry.error)
@@ -226,11 +230,13 @@ const IterationNodeRow = memo(function IterationNodeRow({
{isExpanded && hasChildren && (
<div className={ROW_STYLES.nested}>
{children.map((child) => (
<BlockRow
<EntryNodeRow
key={child.entry.id}
entry={child.entry}
isSelected={selectedEntryId === child.entry.id}
onSelect={onSelectEntry}
node={child}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
))}
</div>
@@ -346,6 +352,8 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
onSelectEntry={onSelectEntry}
isExpanded={expandedNodes.has(iterNode.entry.id)}
onToggle={() => onToggleNode(iterNode.entry.id)}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
))}
</div>
@@ -520,6 +528,8 @@ const EntryNodeRow = memo(function EntryNodeRow({
onSelectEntry={onSelectEntry}
isExpanded={expandedNodes.has(node.entry.id)}
onToggle={() => onToggleNode(node.entry.id)}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
)
}

View File

@@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Content>
</Tooltip.Root>
)}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (

View File

@@ -554,6 +554,7 @@ export function useWorkflowExecution() {
childWorkflowInstanceId: string
iterationCurrent?: number
iterationContainerId?: string
executionOrder?: number
}) => {
if (isStaleExecution()) return
updateConsole(
@@ -564,6 +565,7 @@ export function useWorkflowExecution() {
...(data.iterationContainerId !== undefined && {
iterationContainerId: data.iterationContainerId,
}),
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
},
executionIdRef.current
)

View File

@@ -71,3 +71,38 @@ export function filterProtectedBlocks(
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
}
}
/**
* Returns block IDs ordered so that `batchToggleLocked` will target the desired state.
*
* `batchToggleLocked` determines its target locked state from `!firstBlock.locked`.
* When `targetLocked` is true (lock all), an unlocked block must come first.
* When `targetLocked` is false (unlock all), a locked block must come first.
*
* Returns an empty array when there are no blocks or all blocks already match `targetLocked`.
*
* @param blocks - Record of all blocks in the workflow
* @param targetLocked - The desired locked state for all blocks
* @returns Sorted block IDs, or empty array if no toggle is needed
*/
export function getWorkflowLockToggleIds(
blocks: Record<string, BlockState>,
targetLocked: boolean
): string[] {
const ids = Object.keys(blocks)
if (ids.length === 0) return []
// No-op if all blocks already match the desired state
const allMatch = Object.values(blocks).every((b) => Boolean(b.locked) === targetLocked)
if (allMatch) return []
ids.sort((a, b) => {
const aVal = blocks[a].locked ? 1 : 0
const bVal = blocks[b].locked ? 1 : 0
// To lock all (targetLocked=true): unlocked first (aVal - bVal)
// To unlock all (targetLocked=false): locked first (bVal - aVal)
return targetLocked ? aVal - bVal : bVal - aVal
})
return ids
}

View File

@@ -57,6 +57,7 @@ import {
estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode,
getWorkflowLockToggleIds,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
@@ -393,6 +394,15 @@ const WorkflowContent = React.memo(() => {
const { blocks, edges, lastSaved } = currentWorkflow
const allBlocksLocked = useMemo(() => {
const blockList = Object.values(blocks)
return blockList.length > 0 && blockList.every((b) => b.locked)
}, [blocks])
const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks])
const hasLockedBlocks = useMemo(() => Object.values(blocks).some((b) => b.locked), [blocks])
const isWorkflowReady = useMemo(
() =>
hydration.phase === 'ready' &&
@@ -1175,6 +1185,91 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
const handleToggleWorkflowLock = useCallback(() => {
const currentBlocks = useWorkflowStore.getState().blocks
const allLocked = Object.values(currentBlocks).every((b) => b.locked)
const ids = getWorkflowLockToggleIds(currentBlocks, !allLocked)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
}, [collaborativeBatchToggleLocked])
// Show notification when all blocks in the workflow are locked
const lockNotificationIdRef = useRef<string | null>(null)
const clearLockNotification = useCallback(() => {
if (lockNotificationIdRef.current) {
useNotificationStore.getState().removeNotification(lockNotificationIdRef.current)
lockNotificationIdRef.current = null
}
}, [])
// Clear persisted lock notifications on mount/workflow change (prevents duplicates after reload)
useEffect(() => {
// Reset ref so the main effect creates a fresh notification for the new workflow
clearLockNotification()
if (!activeWorkflowId) return
const store = useNotificationStore.getState()
const stale = store.notifications.filter(
(n) =>
n.workflowId === activeWorkflowId &&
(n.action?.type === 'unlock-workflow' || n.message.startsWith('This workflow is locked'))
)
for (const n of stale) {
store.removeNotification(n.id)
}
}, [activeWorkflowId, clearLockNotification])
const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
useEffect(() => {
if (!isWorkflowReady) return
const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
prevCanAdminRef.current = effectivePermissions.canAdmin
// Clear stale notification when admin status changes so it recreates with correct message
if (canAdminChanged) {
clearLockNotification()
}
if (allBlocksLocked) {
if (lockNotificationIdRef.current) return
const isAdmin = effectivePermissions.canAdmin
lockNotificationIdRef.current = addNotification({
level: 'info',
message: isAdmin
? 'This workflow is locked'
: 'This workflow is locked. Ask an admin to unlock it.',
workflowId: activeWorkflowId || undefined,
...(isAdmin ? { action: { type: 'unlock-workflow' as const, message: '' } } : {}),
})
} else {
clearLockNotification()
}
}, [
allBlocksLocked,
isWorkflowReady,
effectivePermissions.canAdmin,
addNotification,
activeWorkflowId,
clearLockNotification,
])
// Clean up notification on unmount
useEffect(() => clearLockNotification, [clearLockNotification])
// Listen for unlock-workflow events from notification action button
useEffect(() => {
const handleUnlockWorkflow = () => {
const currentBlocks = useWorkflowStore.getState().blocks
const ids = getWorkflowLockToggleIds(currentBlocks, false)
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
}
window.addEventListener('unlock-workflow', handleUnlockWorkflow)
return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow)
}, [collaborativeBatchToggleLocked])
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -2439,6 +2534,16 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
useEffect(() => {
const handleToggleWorkflowLock = (e: CustomEvent<{ blockIds: string[] }>) => {
collaborativeBatchToggleLocked(e.detail.blockIds)
}
window.addEventListener('toggle-workflow-lock', handleToggleWorkflowLock as EventListener)
return () =>
window.removeEventListener('toggle-workflow-lock', handleToggleWorkflowLock as EventListener)
}, [collaborativeBatchToggleLocked])
/**
* Updates container dimensions in displayNodes during drag or keyboard movement.
*/
@@ -3699,7 +3804,11 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo}
canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
hasLockedBlocks={hasLockedBlocks}
onToggleWorkflowLock={handleToggleWorkflowLock}
allBlocksLocked={allBlocksLocked}
canAdmin={effectivePermissions.canAdmin}
hasBlocks={hasBlocks}
/>
</>
)}

View File

@@ -281,6 +281,24 @@ interface ContextMenuProps {
* Set to true when user cannot leave (e.g., last admin)
*/
disableLeave?: boolean
/**
* Callback when lock/unlock is clicked
*/
onToggleLock?: () => void
/**
* Whether to show the lock option (default: false)
* Set to true for workflows that support locking
*/
showLock?: boolean
/**
* Whether the lock option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableLock?: boolean
/**
* Whether the workflow is currently locked (all blocks locked)
*/
isLocked?: boolean
}
/**
@@ -321,6 +339,10 @@ export function ContextMenu({
onLeave,
showLeave = false,
disableLeave = false,
onToggleLock,
showLock = false,
disableLock = false,
isLocked = false,
}: ContextMenuProps) {
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
@@ -372,7 +394,8 @@ export function ContextMenu({
(showRename && onRename) ||
(showCreate && onCreate) ||
(showCreateFolder && onCreateFolder) ||
(showColorChange && onColorChange)
(showColorChange && onColorChange) ||
(showLock && onToggleLock)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
@@ -495,6 +518,19 @@ export function ContextMenu({
</PopoverFolder>
)}
{showLock && onToggleLock && (
<PopoverItem
rootOnly
disabled={disableLock}
onClick={() => {
onToggleLock()
onClose()
}}
>
{isLocked ? 'Unlock' : 'Lock'}
</PopoverItem>
)}
{/* Copy and export actions */}
{hasEditSection && hasCopySection && <PopoverDivider rootOnly />}
{showDuplicate && onDuplicate && (

View File

@@ -6,6 +6,7 @@ import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
@@ -27,6 +28,7 @@ import {
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface WorkflowItemProps {
workflow: WorkflowMetadata
@@ -169,6 +171,29 @@ export function WorkflowItem({
[workflow.id, updateWorkflow]
)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const isActiveWorkflow = workflow.id === activeWorkflowId
const isWorkflowLocked = useWorkflowStore(
useCallback(
(state) => {
if (!isActiveWorkflow) return false
const blockValues = Object.values(state.blocks)
if (blockValues.length === 0) return false
return blockValues.every((block) => block.locked)
},
[isActiveWorkflow]
)
)
const handleToggleLock = useCallback(() => {
if (!isActiveWorkflow) return
const blocks = useWorkflowStore.getState().blocks
const blockIds = getWorkflowLockToggleIds(blocks, !isWorkflowLocked)
if (blockIds.length === 0) return
window.dispatchEvent(new CustomEvent('toggle-workflow-lock', { detail: { blockIds } }))
}, [isActiveWorkflow, isWorkflowLocked])
const isEditingRef = useRef(false)
const {
@@ -461,6 +486,10 @@ export function WorkflowItem({
disableExport={!userPermissions.canEdit}
disableColorChange={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
onToggleLock={handleToggleLock}
showLock={isActiveWorkflow && !isMixedSelection && selectedWorkflows.size <= 1}
disableLock={!userPermissions.canAdmin}
isLocked={isWorkflowLocked}
/>
<DeleteModal

View File

@@ -84,6 +84,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
],
placeholder: 'Select Confluence account',
required: true,
@@ -414,6 +415,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' },
@@ -432,7 +435,24 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
{ label: 'List Space Labels', id: 'list_space_labels' },
// Space Operations
{ label: 'Get Space', id: 'get_space' },
{ label: 'Create Space', id: 'create_space' },
{ label: 'Update Space', id: 'update_space' },
{ label: 'Delete Space', id: 'delete_space' },
{ label: 'List Spaces', id: 'list_spaces' },
// Space Property Operations
{ label: 'List Space Properties', id: 'list_space_properties' },
{ label: 'Create Space Property', id: 'create_space_property' },
{ label: 'Delete Space Property', id: 'delete_space_property' },
// Space Permission Operations
{ label: 'List Space Permissions', id: 'list_space_permissions' },
// Page Descendant Operations
{ label: 'Get Page Descendants', id: 'get_page_descendants' },
// Task Operations
{ label: 'List Tasks', id: 'list_tasks' },
{ label: 'Get Task', id: 'get_task' },
{ label: 'Update Task', id: 'update_task' },
// User Operations
{ label: 'Get User', id: 'get_user' },
],
value: () => 'read',
},
@@ -472,6 +492,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
'read:task:confluence',
'write:task:confluence',
'delete:blogpost:confluence',
'write:space:confluence',
'delete:space:confluence',
'read:space.property:confluence',
'write:space.property:confluence',
'read:space.permission:confluence',
],
placeholder: 'Select Confluence account',
required: true,
@@ -507,13 +536,26 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_pages_in_space',
'list_blogposts',
'get_blogpost',
'update_blogpost',
'delete_blogpost',
'list_blogposts_in_space',
'search',
'search_in_space',
'get_space',
'create_space',
'update_space',
'delete_space',
'list_spaces',
'get_pages_by_label',
'list_space_labels',
'list_space_permissions',
'list_space_properties',
'create_space_property',
'delete_space_property',
'list_tasks',
'get_task',
'update_task',
'get_user',
],
not: true,
},
@@ -537,6 +579,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'get_page_version',
'list_page_properties',
'create_page_property',
'get_page_descendants',
],
},
},
@@ -553,13 +596,26 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_pages_in_space',
'list_blogposts',
'get_blogpost',
'update_blogpost',
'delete_blogpost',
'list_blogposts_in_space',
'search',
'search_in_space',
'get_space',
'create_space',
'update_space',
'delete_space',
'list_spaces',
'get_pages_by_label',
'list_space_labels',
'list_space_permissions',
'list_space_properties',
'create_space_property',
'delete_space_property',
'list_tasks',
'get_task',
'update_task',
'get_user',
],
not: true,
},
@@ -583,6 +639,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'get_page_version',
'list_page_properties',
'create_page_property',
'get_page_descendants',
],
},
},
@@ -597,11 +654,17 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
value: [
'create',
'get_space',
'update_space',
'delete_space',
'list_pages_in_space',
'search_in_space',
'create_blogpost',
'list_blogposts_in_space',
'list_space_labels',
'list_space_permissions',
'list_space_properties',
'create_space_property',
'delete_space_property',
],
},
},
@@ -611,7 +674,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',
@@ -621,6 +687,86 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
required: true,
condition: { field: 'operation', value: 'get_page_version' },
},
{
id: 'accountId',
title: 'Account ID',
type: 'short-input',
placeholder: 'Enter Atlassian account ID',
required: true,
condition: { field: 'operation', value: 'get_user' },
},
{
id: 'taskId',
title: 'Task ID',
type: 'short-input',
placeholder: 'Enter task ID',
required: true,
condition: { field: 'operation', value: ['get_task', 'update_task'] },
},
{
id: 'taskStatus',
title: 'Task Status',
type: 'dropdown',
options: [
{ label: 'Complete', id: 'complete' },
{ label: 'Incomplete', id: 'incomplete' },
],
value: () => 'complete',
condition: { field: 'operation', value: 'update_task' },
},
{
id: 'taskAssignedTo',
title: 'Assigned To',
type: 'short-input',
placeholder: 'Filter by assignee account ID (optional)',
condition: { field: 'operation', value: 'list_tasks' },
},
{
id: 'spaceName',
title: 'Space Name',
type: 'short-input',
placeholder: 'Enter space name',
required: true,
condition: { field: 'operation', value: 'create_space' },
},
{
id: 'spaceKey',
title: 'Space Key',
type: 'short-input',
placeholder: 'Enter space key (e.g., MYSPACE)',
required: true,
condition: { field: 'operation', value: 'create_space' },
},
{
id: 'spaceDescription',
title: 'Description',
type: 'long-input',
placeholder: 'Enter space description (optional)',
condition: { field: 'operation', value: ['create_space', 'update_space'] },
},
{
id: 'spacePropertyKey',
title: 'Property Key',
type: 'short-input',
placeholder: 'Enter property key/name',
required: true,
condition: { field: 'operation', value: 'create_space_property' },
},
{
id: 'spacePropertyValue',
title: 'Property Value',
type: 'long-input',
placeholder: 'Enter property value (JSON supported)',
condition: { field: 'operation', value: 'create_space_property' },
},
{
id: 'spacePropertyId',
title: 'Property ID',
type: 'short-input',
placeholder: 'Enter property ID to delete',
required: true,
condition: { field: 'operation', value: 'delete_space_property' },
},
{
id: 'propertyKey',
title: 'Property Key',
@@ -650,14 +796,20 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
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', 'update_space'],
},
},
{
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',
@@ -813,6 +965,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_labels',
'get_pages_by_label',
'list_space_labels',
'get_page_descendants',
'list_space_permissions',
'list_space_properties',
'list_tasks',
],
},
},
@@ -836,6 +992,10 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_labels',
'get_pages_by_label',
'list_space_labels',
'get_page_descendants',
'list_space_permissions',
'list_space_properties',
'list_tasks',
],
},
},
@@ -921,7 +1081,27 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'confluence_list_space_labels',
// Space Tools
'confluence_get_space',
'confluence_create_space',
'confluence_update_space',
'confluence_delete_space',
'confluence_list_spaces',
// Space Property Tools
'confluence_list_space_properties',
'confluence_create_space_property',
'confluence_delete_space_property',
// Space Permission Tools
'confluence_list_space_permissions',
// Page Descendant Tools
'confluence_get_page_descendants',
// Task Tools
'confluence_list_tasks',
'confluence_get_task',
'confluence_update_task',
// Blog Post Update/Delete
'confluence_update_blogpost',
'confluence_delete_blogpost',
// User Tools
'confluence_get_user',
],
config: {
tool: (params) => {
@@ -965,6 +1145,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
@@ -997,8 +1181,37 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
// Space Operations
case 'get_space':
return 'confluence_get_space'
case 'create_space':
return 'confluence_create_space'
case 'update_space':
return 'confluence_update_space'
case 'delete_space':
return 'confluence_delete_space'
case 'list_spaces':
return 'confluence_list_spaces'
// Space Property Operations
case 'list_space_properties':
return 'confluence_list_space_properties'
case 'create_space_property':
return 'confluence_create_space_property'
case 'delete_space_property':
return 'confluence_delete_space_property'
// Space Permission Operations
case 'list_space_permissions':
return 'confluence_list_space_permissions'
// Page Descendant Operations
case 'get_page_descendants':
return 'confluence_get_page_descendants'
// Task Operations
case 'list_tasks':
return 'confluence_list_tasks'
case 'get_task':
return 'confluence_get_task'
case 'update_task':
return 'confluence_update_task'
// User Operations
case 'get_user':
return 'confluence_get_user'
default:
return 'confluence_retrieve'
}
@@ -1013,6 +1226,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
attachmentComment,
blogPostId,
versionNumber,
accountId,
propertyKey,
propertyValue,
propertyId,
@@ -1022,6 +1236,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
purge,
bodyFormat,
cursor,
taskId,
taskStatus,
taskAssignedTo,
spaceName,
spaceKey,
spaceDescription,
spacePropertyKey,
spacePropertyValue,
spacePropertyId,
...rest
} = params
@@ -1069,8 +1292,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}
// Operations that support generic cursor pagination.
// get_pages_by_label and list_space_labels have dedicated handlers
// below that pass cursor along with their required params (labelId, spaceId).
// get_pages_by_label, list_space_labels, and list_tasks have dedicated handlers
// below that pass cursor along with their required params.
const supportsCursor = [
'list_attachments',
'list_spaces',
@@ -1081,6 +1304,9 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
'list_page_versions',
'list_page_properties',
'list_labels',
'get_page_descendants',
'list_space_permissions',
'list_space_properties',
]
if (supportsCursor.includes(operation) && cursor) {
@@ -1152,6 +1378,122 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}
}
if (operation === 'get_user') {
return {
credential: oauthCredential,
operation,
accountId: accountId ? String(accountId).trim() : undefined,
...rest,
}
}
if (operation === 'update_blogpost' || operation === 'delete_blogpost') {
return {
credential: oauthCredential,
operation,
blogPostId: blogPostId || undefined,
...rest,
}
}
if (operation === 'create_space') {
return {
credential: oauthCredential,
operation,
name: spaceName,
key: spaceKey,
description: spaceDescription,
...rest,
}
}
if (operation === 'update_space') {
return {
credential: oauthCredential,
operation,
name: spaceName || rest.title,
description: spaceDescription,
...rest,
}
}
if (operation === 'delete_space') {
return {
credential: oauthCredential,
operation,
...rest,
}
}
if (operation === 'create_space_property') {
return {
credential: oauthCredential,
operation,
key: spacePropertyKey,
value: spacePropertyValue,
...rest,
}
}
if (operation === 'delete_space_property') {
return {
credential: oauthCredential,
operation,
propertyId: spacePropertyId,
...rest,
}
}
if (operation === 'list_space_permissions' || operation === 'list_space_properties') {
return {
credential: oauthCredential,
operation,
cursor: cursor || undefined,
...rest,
}
}
if (operation === 'get_page_descendants') {
return {
credential: oauthCredential,
pageId: effectivePageId,
operation,
cursor: cursor || undefined,
...rest,
}
}
if (operation === 'get_task') {
return {
credential: oauthCredential,
operation,
taskId,
...rest,
}
}
if (operation === 'update_task') {
return {
credential: oauthCredential,
operation,
taskId,
status: taskStatus,
...rest,
}
}
if (operation === 'list_tasks') {
return {
credential: oauthCredential,
operation,
pageId: effectivePageId || undefined,
assignedTo: taskAssignedTo || undefined,
status: taskStatus || undefined,
cursor: cursor || undefined,
...rest,
}
}
return {
credential: oauthCredential,
pageId: effectivePageId || undefined,
@@ -1171,6 +1513,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' },
versionNumber: { type: 'number', description: 'Page version number' },
accountId: { type: 'string', description: 'Atlassian account ID' },
propertyKey: { type: 'string', description: 'Property key/name' },
propertyValue: { type: 'json', description: 'Property value (JSON)' },
title: { type: 'string', description: 'Page or blog post title' },
@@ -1192,6 +1535,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
bodyFormat: { type: 'string', description: 'Body format for comments' },
limit: { type: 'number', description: 'Maximum number of results' },
cursor: { type: 'string', description: 'Pagination cursor from previous response' },
taskId: { type: 'string', description: 'Task identifier' },
taskStatus: { type: 'string', description: 'Task status (complete or incomplete)' },
taskAssignedTo: { type: 'string', description: 'Filter tasks by assignee account ID' },
spaceName: { type: 'string', description: 'Space name for create/update' },
spaceKey: { type: 'string', description: 'Space key for create' },
spaceDescription: { type: 'string', description: 'Space description' },
spacePropertyKey: { type: 'string', description: 'Space property key' },
spacePropertyValue: { type: 'json', description: 'Space property value' },
spacePropertyId: { type: 'string', description: 'Space property identifier' },
},
outputs: {
ts: { type: 'string', description: 'Timestamp' },
@@ -1242,6 +1594,23 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
propertyId: { type: 'string', description: 'Property identifier' },
propertyKey: { type: 'string', description: 'Property key' },
propertyValue: { type: 'json', description: 'Property value' },
// User Results
accountId: { type: 'string', description: 'Atlassian account ID' },
displayName: { type: 'string', description: 'User display name' },
email: { type: 'string', description: 'User email address' },
accountType: { type: 'string', description: 'Account type (atlassian, app, customer)' },
profilePicture: { type: 'string', description: 'Path to user profile picture' },
publicName: { type: 'string', description: 'User public name' },
// Task Results
tasks: { type: 'array', description: 'List of tasks' },
taskId: { type: 'string', description: 'Task identifier' },
// Descendant Results
descendants: { type: 'array', description: 'List of descendant pages' },
// Permission Results
permissions: { type: 'array', description: 'List of space permissions' },
// Space Property Results
homepageId: { type: 'string', description: 'Space homepage ID' },
description: { type: 'json', description: 'Space description' },
// Pagination
nextCursor: { type: 'string', description: 'Cursor for fetching next page of results' },
},

View File

@@ -0,0 +1,187 @@
import { DevinIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const DevinBlock: BlockConfig = {
type: 'devin',
name: 'Devin',
description: 'Autonomous AI software engineer',
longDescription:
'Integrate Devin into your workflow. Create sessions to assign coding tasks, send messages to guide active sessions, and retrieve session status and results. Devin autonomously writes, runs, and tests code.',
bestPractices: `
- Write clear, specific prompts describing the task, expected outcome, and any constraints.
- Use playbook IDs to standardize recurring task patterns across sessions.
- Set ACU limits to control cost for long-running tasks.
- Use Get Session to poll for completion status before consuming structured output.
- Send Message auto-resumes suspended sessions — no need to resume separately.
`,
docsLink: 'https://docs.sim.ai/tools/devin',
category: 'tools',
bgColor: '#12141A',
icon: DevinIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Session', id: 'create_session' },
{ label: 'Get Session', id: 'get_session' },
{ label: 'List Sessions', id: 'list_sessions' },
{ label: 'Send Message', id: 'send_message' },
],
value: () => 'create_session',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Devin API key (cog_...)',
password: true,
required: true,
},
{
id: 'prompt',
title: 'Prompt',
type: 'long-input',
placeholder: 'Describe the task for Devin...',
required: { field: 'operation', value: 'create_session' },
condition: { field: 'operation', value: 'create_session' },
wandConfig: {
enabled: true,
prompt: `You are an expert at writing clear, actionable prompts for Devin, an autonomous AI software engineer. Generate or refine a task prompt based on the user's request.
Current prompt: {context}
RULES:
1. Be specific about the expected outcome and deliverables
2. Include relevant technical context (languages, frameworks, repos)
3. Specify any constraints (don't modify certain files, follow certain patterns)
4. Break complex tasks into clear steps when helpful
5. Return ONLY the prompt text, no markdown formatting or explanations`,
placeholder: 'Describe what you want Devin to do...',
},
},
{
id: 'playbookId',
title: 'Playbook ID',
type: 'short-input',
placeholder: 'Optional playbook ID to guide the session',
condition: { field: 'operation', value: 'create_session' },
mode: 'advanced',
},
{
id: 'maxAcuLimit',
title: 'Max ACU Limit',
type: 'short-input',
placeholder: 'Maximum ACU budget for this session',
condition: { field: 'operation', value: 'create_session' },
mode: 'advanced',
},
{
id: 'tags',
title: 'Tags',
type: 'short-input',
placeholder: 'Comma-separated tags',
condition: { field: 'operation', value: 'create_session' },
mode: 'advanced',
},
{
id: 'sessionId',
title: 'Session ID',
type: 'short-input',
placeholder: 'Enter session ID',
required: { field: 'operation', value: ['get_session', 'send_message'] },
condition: { field: 'operation', value: ['get_session', 'send_message'] },
},
{
id: 'message',
title: 'Message',
type: 'long-input',
placeholder: 'Enter message to send to Devin...',
required: { field: 'operation', value: 'send_message' },
condition: { field: 'operation', value: 'send_message' },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: 'Number of sessions (1-200, default: 100)',
condition: { field: 'operation', value: 'list_sessions' },
mode: 'advanced',
},
],
tools: {
access: [
'devin_create_session',
'devin_get_session',
'devin_list_sessions',
'devin_send_message',
],
config: {
tool: (params) => `devin_${params.operation}`,
params: (params) => {
if (params.maxAcuLimit != null && params.maxAcuLimit !== '') {
params.maxAcuLimit = Number(params.maxAcuLimit)
}
if (params.limit != null && params.limit !== '') {
params.limit = Number(params.limit)
}
return params
},
},
},
inputs: {
prompt: { type: 'string', description: 'Task prompt for Devin' },
sessionId: { type: 'string', description: 'Session ID' },
message: { type: 'string', description: 'Message to send to the session' },
apiKey: { type: 'string', description: 'Devin API key' },
playbookId: { type: 'string', description: 'Playbook ID to guide the session' },
maxAcuLimit: { type: 'number', description: 'Maximum ACU limit' },
tags: { type: 'string', description: 'Comma-separated tags' },
limit: { type: 'number', description: 'Number of sessions to return' },
},
outputs: {
sessionId: { type: 'string', description: 'Session identifier' },
url: { type: 'string', description: 'URL to view the session in Devin UI' },
status: {
type: 'string',
description: 'Session status (new, claimed, running, exit, error, suspended, resuming)',
},
statusDetail: {
type: 'string',
description: 'Detailed status (working, waiting_for_user, finished, etc.)',
condition: { field: 'operation', value: 'list_sessions', not: true },
},
title: { type: 'string', description: 'Session title' },
createdAt: { type: 'number', description: 'Creation timestamp (Unix)' },
updatedAt: { type: 'number', description: 'Last updated timestamp (Unix)' },
acusConsumed: {
type: 'number',
description: 'ACUs consumed',
condition: { field: 'operation', value: 'list_sessions', not: true },
},
tags: { type: 'json', description: 'Session tags' },
pullRequests: {
type: 'json',
description: 'Pull requests created during the session',
condition: { field: 'operation', value: 'list_sessions', not: true },
},
structuredOutput: {
type: 'json',
description: 'Structured output from the session',
condition: { field: 'operation', value: 'list_sessions', not: true },
},
playbookId: {
type: 'string',
description: 'Associated playbook ID',
condition: { field: 'operation', value: 'list_sessions', not: true },
},
sessions: {
type: 'json',
description: 'List of sessions',
condition: { field: 'operation', value: 'list_sessions' },
},
},
}

View File

@@ -0,0 +1,256 @@
import { GoogleBigQueryIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const GoogleBigQueryBlock: BlockConfig = {
type: 'google_bigquery',
name: 'Google BigQuery',
description: 'Query, list, and insert data in Google BigQuery',
longDescription:
'Connect to Google BigQuery to run SQL queries, list datasets and tables, get table metadata, and insert rows.',
docsLink: 'https://docs.sim.ai/tools/google_bigquery',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleBigQueryIcon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Run Query', id: 'query' },
{ label: 'List Datasets', id: 'list_datasets' },
{ label: 'List Tables', id: 'list_tables' },
{ label: 'Get Table', id: 'get_table' },
{ label: 'Insert Rows', id: 'insert_rows' },
],
value: () => 'query',
},
{
id: 'credential',
title: 'Google Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true,
serviceId: 'google-bigquery',
requiredScopes: ['https://www.googleapis.com/auth/bigquery'],
placeholder: 'Select Google account',
},
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'projectId',
title: 'Project ID',
type: 'short-input',
placeholder: 'Enter Google Cloud project ID',
required: true,
},
{
id: 'query',
title: 'SQL Query',
type: 'long-input',
placeholder: 'SELECT * FROM `project.dataset.table` LIMIT 100',
condition: { field: 'operation', value: 'query' },
required: { field: 'operation', value: 'query' },
wandConfig: {
enabled: true,
prompt: `Generate a BigQuery Standard SQL query based on the user's description.
The query should:
- Use Standard SQL syntax (not Legacy SQL)
- Be well-formatted and efficient
- Include appropriate LIMIT clauses when applicable
Examples:
- "get all users" -> SELECT * FROM \`project.dataset.users\` LIMIT 1000
- "count orders by status" -> SELECT status, COUNT(*) as count FROM \`project.dataset.orders\` GROUP BY status
- "recent events" -> SELECT * FROM \`project.dataset.events\` ORDER BY created_at DESC LIMIT 100
Return ONLY the SQL query - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the query you want to run...',
},
},
{
id: 'useLegacySql',
title: 'Use Legacy SQL',
type: 'switch',
condition: { field: 'operation', value: 'query' },
},
{
id: 'maxResults',
title: 'Max Results',
type: 'short-input',
placeholder: 'Maximum rows to return',
condition: { field: 'operation', value: ['query', 'list_datasets', 'list_tables'] },
},
{
id: 'defaultDatasetId',
title: 'Default Dataset',
type: 'short-input',
placeholder: 'Default dataset for unqualified table names',
condition: { field: 'operation', value: 'query' },
},
{
id: 'location',
title: 'Location',
type: 'short-input',
placeholder: 'Processing location (e.g., US, EU)',
condition: { field: 'operation', value: 'query' },
},
{
id: 'datasetId',
title: 'Dataset ID',
type: 'short-input',
placeholder: 'Enter BigQuery dataset ID',
condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
},
{
id: 'tableId',
title: 'Table ID',
type: 'short-input',
placeholder: 'Enter BigQuery table ID',
condition: { field: 'operation', value: ['get_table', 'insert_rows'] },
required: { field: 'operation', value: ['get_table', 'insert_rows'] },
},
{
id: 'rows',
title: 'Rows',
type: 'long-input',
placeholder: '[{"column1": "value1", "column2": 42}]',
condition: { field: 'operation', value: 'insert_rows' },
required: { field: 'operation', value: 'insert_rows' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON array of row objects for BigQuery insertion based on the user's description.
Each row should be a JSON object where keys are column names and values match the expected types.
Examples:
- "3 users" -> [{"name": "Alice", "email": "alice@example.com"}, {"name": "Bob", "email": "bob@example.com"}, {"name": "Charlie", "email": "charlie@example.com"}]
- "order record" -> [{"order_id": "ORD-001", "amount": 99.99, "status": "pending"}]
Return ONLY the JSON array - no explanations, no wrapping, no extra text.`,
placeholder: 'Describe the rows to insert...',
generationType: 'json-object',
},
},
{
id: 'skipInvalidRows',
title: 'Skip Invalid Rows',
type: 'switch',
condition: { field: 'operation', value: 'insert_rows' },
},
{
id: 'ignoreUnknownValues',
title: 'Ignore Unknown Values',
type: 'switch',
condition: { field: 'operation', value: 'insert_rows' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Pagination token',
condition: { field: 'operation', value: ['list_datasets', 'list_tables'] },
},
],
tools: {
access: [
'google_bigquery_query',
'google_bigquery_list_datasets',
'google_bigquery_list_tables',
'google_bigquery_get_table',
'google_bigquery_insert_rows',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'query':
return 'google_bigquery_query'
case 'list_datasets':
return 'google_bigquery_list_datasets'
case 'list_tables':
return 'google_bigquery_list_tables'
case 'get_table':
return 'google_bigquery_get_table'
case 'insert_rows':
return 'google_bigquery_insert_rows'
default:
throw new Error(`Invalid Google BigQuery operation: ${params.operation}`)
}
},
params: (params) => {
const { oauthCredential, rows, maxResults, ...rest } = params
return {
...rest,
oauthCredential,
...(rows && { rows: typeof rows === 'string' ? rows : JSON.stringify(rows) }),
...(maxResults !== undefined && maxResults !== '' && { maxResults: Number(maxResults) }),
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google BigQuery OAuth credential' },
projectId: { type: 'string', description: 'Google Cloud project ID' },
query: { type: 'string', description: 'SQL query to execute' },
useLegacySql: { type: 'boolean', description: 'Whether to use legacy SQL syntax' },
maxResults: { type: 'number', description: 'Maximum number of results to return' },
defaultDatasetId: {
type: 'string',
description: 'Default dataset for unqualified table names',
},
location: { type: 'string', description: 'Processing location' },
datasetId: { type: 'string', description: 'BigQuery dataset ID' },
tableId: { type: 'string', description: 'BigQuery table ID' },
rows: { type: 'string', description: 'JSON array of row objects to insert' },
skipInvalidRows: { type: 'boolean', description: 'Whether to skip invalid rows during insert' },
ignoreUnknownValues: {
type: 'boolean',
description: 'Whether to ignore unknown column values',
},
pageToken: { type: 'string', description: 'Pagination token' },
},
outputs: {
columns: { type: 'json', description: 'Array of column names (query)' },
rows: { type: 'json', description: 'Array of row objects (query)' },
totalRows: { type: 'string', description: 'Total number of rows (query)' },
jobComplete: { type: 'boolean', description: 'Whether the query completed (query)' },
totalBytesProcessed: { type: 'string', description: 'Bytes processed (query)' },
cacheHit: { type: 'boolean', description: 'Whether result was cached (query)' },
jobReference: { type: 'json', description: 'Job reference for incomplete queries (query)' },
pageToken: { type: 'string', description: 'Token for additional result pages (query)' },
datasets: { type: 'json', description: 'Array of dataset objects (list_datasets)' },
tables: { type: 'json', description: 'Array of table objects (list_tables)' },
totalItems: { type: 'number', description: 'Total items count (list_tables)' },
tableId: { type: 'string', description: 'Table ID (get_table)' },
datasetId: { type: 'string', description: 'Dataset ID (get_table)' },
type: { type: 'string', description: 'Table type (get_table)' },
description: { type: 'string', description: 'Table description (get_table)' },
numRows: { type: 'string', description: 'Row count (get_table)' },
numBytes: { type: 'string', description: 'Size in bytes (get_table)' },
schema: { type: 'json', description: 'Column definitions (get_table)' },
creationTime: { type: 'string', description: 'Creation time (get_table)' },
lastModifiedTime: { type: 'string', description: 'Last modified time (get_table)' },
location: { type: 'string', description: 'Data location (get_table)' },
insertedRows: { type: 'number', description: 'Rows inserted (insert_rows)' },
errors: { type: 'json', description: 'Insert errors (insert_rows)' },
nextPageToken: { type: 'string', description: 'Token for next page of results' },
},
}

View File

@@ -440,6 +440,36 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`,
placeholder: 'Describe the range (e.g., "first 50 rows" or "column A")...',
},
},
// Read Filter Fields (advanced mode only)
{
id: 'filterColumn',
title: 'Filter Column',
type: 'short-input',
placeholder: 'Column header name to filter on (e.g., Email, Status)',
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
{
id: 'filterValue',
title: 'Filter Value',
type: 'short-input',
placeholder: 'Value to match against',
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
{
id: 'filterMatchType',
title: 'Match Type',
type: 'dropdown',
options: [
{ label: 'Contains', id: 'contains' },
{ label: 'Exact Match', id: 'exact' },
{ label: 'Starts With', id: 'starts_with' },
{ label: 'Ends With', id: 'ends_with' },
],
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
},
// Write-specific Fields
{
id: 'values',
@@ -748,6 +778,9 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
batchData,
sheetId,
destinationSpreadsheetId,
filterColumn,
filterValue,
filterMatchType,
...rest
} = params
@@ -836,6 +869,11 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues,
oauthCredential,
...(filterColumn ? { filterColumn: (filterColumn as string).trim() } : {}),
...(filterValue !== undefined && filterValue !== ''
? { filterValue: filterValue as string }
: {}),
...(filterMatchType ? { filterMatchType: filterMatchType as string } : {}),
}
},
},
@@ -858,6 +896,12 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
type: 'string',
description: 'Destination spreadsheet ID for copy',
},
filterColumn: { type: 'string', description: 'Column header name to filter on' },
filterValue: { type: 'string', description: 'Value to match against the filter column' },
filterMatchType: {
type: 'string',
description: 'Match type: contains, exact, starts_with, or ends_with',
},
},
outputs: {
// Read outputs

View File

@@ -0,0 +1,262 @@
import { GoogleTasksIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GoogleTasksResponse } from '@/tools/google_tasks/types'
export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
type: 'google_tasks',
name: 'Google Tasks',
description: 'Manage Google Tasks',
longDescription:
'Integrate Google Tasks into your workflow. Create, read, update, delete, and list tasks and task lists.',
docsLink: 'https://docs.sim.ai/tools/google_tasks',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleTasksIcon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Task', id: 'create' },
{ label: 'List Tasks', id: 'list' },
{ label: 'Get Task', id: 'get' },
{ label: 'Update Task', id: 'update' },
{ label: 'Delete Task', id: 'delete' },
{ label: 'List Task Lists', id: 'list_task_lists' },
],
value: () => 'create',
},
{
id: 'credential',
title: 'Google Tasks Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true,
serviceId: 'google-tasks',
requiredScopes: ['https://www.googleapis.com/auth/tasks'],
placeholder: 'Select Google Tasks account',
},
{
id: 'manualCredential',
title: 'Google Tasks Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Task List ID - shown for all task operations (not list_task_lists)
{
id: 'taskListId',
title: 'Task List ID',
type: 'short-input',
placeholder: 'Task list ID (leave empty for default list)',
condition: { field: 'operation', value: 'list_task_lists', not: true },
},
// Create Task Fields
{
id: 'title',
title: 'Title',
type: 'short-input',
placeholder: 'Buy groceries',
condition: { field: 'operation', value: 'create' },
required: { field: 'operation', value: 'create' },
},
{
id: 'notes',
title: 'Notes',
type: 'long-input',
placeholder: 'Task notes or description',
condition: { field: 'operation', value: 'create' },
},
{
id: 'due',
title: 'Due Date',
type: 'short-input',
placeholder: '2025-06-03T00:00:00.000Z',
condition: { field: 'operation', value: 'create' },
wandConfig: {
enabled: true,
prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
Examples:
- "tomorrow" -> Calculate tomorrow's date at 00:00:00.000Z
- "next Friday" -> Calculate the next Friday's date at 00:00:00.000Z
- "June 15" -> 2025-06-15T00:00:00.000Z
Return ONLY the timestamp - no explanations, no extra text.`,
},
},
{
id: 'status',
title: 'Status',
type: 'dropdown',
condition: { field: 'operation', value: 'create' },
options: [
{ label: 'Needs Action', id: 'needsAction' },
{ label: 'Completed', id: 'completed' },
],
},
// Get/Update/Delete Task Fields - Task ID
{
id: 'taskId',
title: 'Task ID',
type: 'short-input',
placeholder: 'Task ID',
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
required: { field: 'operation', value: ['get', 'update', 'delete'] },
},
// Update Task Fields
{
id: 'title',
title: 'New Title',
type: 'short-input',
placeholder: 'Updated task title',
condition: { field: 'operation', value: 'update' },
},
{
id: 'notes',
title: 'New Notes',
type: 'long-input',
placeholder: 'Updated task notes',
condition: { field: 'operation', value: 'update' },
},
{
id: 'due',
title: 'New Due Date',
type: 'short-input',
placeholder: '2025-06-03T00:00:00.000Z',
condition: { field: 'operation', value: 'update' },
wandConfig: {
enabled: true,
prompt: `Generate an RFC 3339 timestamp in UTC based on the user's description.
The timestamp should be in the format: YYYY-MM-DDTHH:MM:SS.000Z (UTC timezone).
Examples:
- "tomorrow" -> Calculate tomorrow's date at 00:00:00.000Z
- "next Friday" -> Calculate the next Friday's date at 00:00:00.000Z
- "June 15" -> 2025-06-15T00:00:00.000Z
Return ONLY the timestamp - no explanations, no extra text.`,
},
},
{
id: 'status',
title: 'New Status',
type: 'dropdown',
condition: { field: 'operation', value: 'update' },
options: [
{ label: 'Needs Action', id: 'needsAction' },
{ label: 'Completed', id: 'completed' },
],
},
// List Tasks Fields
{
id: 'maxResults',
title: 'Max Results',
type: 'short-input',
placeholder: '20',
condition: { field: 'operation', value: ['list', 'list_task_lists'] },
},
{
id: 'showCompleted',
title: 'Show Completed',
type: 'dropdown',
condition: { field: 'operation', value: 'list' },
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
},
],
tools: {
access: [
'google_tasks_create',
'google_tasks_list',
'google_tasks_get',
'google_tasks_update',
'google_tasks_delete',
'google_tasks_list_task_lists',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'create':
return 'google_tasks_create'
case 'list':
return 'google_tasks_list'
case 'get':
return 'google_tasks_get'
case 'update':
return 'google_tasks_update'
case 'delete':
return 'google_tasks_delete'
case 'list_task_lists':
return 'google_tasks_list_task_lists'
default:
throw new Error(`Invalid Google Tasks operation: ${params.operation}`)
}
},
params: (params) => {
const { oauthCredential, operation, showCompleted, maxResults, ...rest } = params
const processedParams: Record<string, unknown> = { ...rest }
if (maxResults && typeof maxResults === 'string') {
processedParams.maxResults = Number.parseInt(maxResults, 10)
}
if (showCompleted !== undefined) {
processedParams.showCompleted = showCompleted === 'true'
}
return {
oauthCredential,
...processedParams,
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Tasks access token' },
taskListId: { type: 'string', description: 'Task list identifier' },
title: { type: 'string', description: 'Task title' },
notes: { type: 'string', description: 'Task notes' },
due: { type: 'string', description: 'Task due date' },
status: { type: 'string', description: 'Task status' },
taskId: { type: 'string', description: 'Task identifier' },
maxResults: { type: 'string', description: 'Maximum number of results' },
showCompleted: { type: 'string', description: 'Whether to show completed tasks' },
},
outputs: {
id: { type: 'string', description: 'Task ID' },
title: { type: 'string', description: 'Task title' },
notes: { type: 'string', description: 'Task notes' },
status: { type: 'string', description: 'Task status' },
due: { type: 'string', description: 'Due date' },
updated: { type: 'string', description: 'Last modification time' },
selfLink: { type: 'string', description: 'URL for the task' },
webViewLink: { type: 'string', description: 'Link to task in Google Tasks UI' },
parent: { type: 'string', description: 'Parent task ID' },
position: { type: 'string', description: 'Position among sibling tasks' },
completed: { type: 'string', description: 'Completion date' },
deleted: { type: 'boolean', description: 'Whether the task is deleted' },
tasks: { type: 'json', description: 'Array of tasks (list operation)' },
taskLists: { type: 'json', description: 'Array of task lists (list_task_lists operation)' },
taskId: { type: 'string', description: 'Deleted task ID (delete operation)' },
nextPageToken: { type: 'string', description: 'Token for next page of results' },
},
}

View File

@@ -0,0 +1,215 @@
import { GoogleTranslateIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
const SUPPORTED_LANGUAGES = [
{ label: 'Afrikaans', id: 'af' },
{ label: 'Albanian', id: 'sq' },
{ label: 'Amharic', id: 'am' },
{ label: 'Arabic', id: 'ar' },
{ label: 'Armenian', id: 'hy' },
{ label: 'Assamese', id: 'as' },
{ label: 'Aymara', id: 'ay' },
{ label: 'Azerbaijani', id: 'az' },
{ label: 'Bambara', id: 'bm' },
{ label: 'Basque', id: 'eu' },
{ label: 'Belarusian', id: 'be' },
{ label: 'Bengali', id: 'bn' },
{ label: 'Bhojpuri', id: 'bho' },
{ label: 'Bosnian', id: 'bs' },
{ label: 'Bulgarian', id: 'bg' },
{ label: 'Catalan', id: 'ca' },
{ label: 'Cebuano', id: 'ceb' },
{ label: 'Chinese (Simplified)', id: 'zh-CN' },
{ label: 'Chinese (Traditional)', id: 'zh-TW' },
{ label: 'Corsican', id: 'co' },
{ label: 'Croatian', id: 'hr' },
{ label: 'Czech', id: 'cs' },
{ label: 'Danish', id: 'da' },
{ label: 'Dhivehi', id: 'dv' },
{ label: 'Dogri', id: 'doi' },
{ label: 'Dutch', id: 'nl' },
{ label: 'English', id: 'en' },
{ label: 'Esperanto', id: 'eo' },
{ label: 'Estonian', id: 'et' },
{ label: 'Ewe', id: 'ee' },
{ label: 'Filipino', id: 'tl' },
{ label: 'Finnish', id: 'fi' },
{ label: 'French', id: 'fr' },
{ label: 'Frisian', id: 'fy' },
{ label: 'Galician', id: 'gl' },
{ label: 'Georgian', id: 'ka' },
{ label: 'German', id: 'de' },
{ label: 'Greek', id: 'el' },
{ label: 'Guarani', id: 'gn' },
{ label: 'Gujarati', id: 'gu' },
{ label: 'Haitian Creole', id: 'ht' },
{ label: 'Hausa', id: 'ha' },
{ label: 'Hawaiian', id: 'haw' },
{ label: 'Hebrew', id: 'he' },
{ label: 'Hindi', id: 'hi' },
{ label: 'Hmong', id: 'hmn' },
{ label: 'Hungarian', id: 'hu' },
{ label: 'Icelandic', id: 'is' },
{ label: 'Igbo', id: 'ig' },
{ label: 'Ilocano', id: 'ilo' },
{ label: 'Indonesian', id: 'id' },
{ label: 'Irish', id: 'ga' },
{ label: 'Italian', id: 'it' },
{ label: 'Japanese', id: 'ja' },
{ label: 'Javanese', id: 'jv' },
{ label: 'Kannada', id: 'kn' },
{ label: 'Kazakh', id: 'kk' },
{ label: 'Khmer', id: 'km' },
{ label: 'Kinyarwanda', id: 'rw' },
{ label: 'Konkani', id: 'gom' },
{ label: 'Korean', id: 'ko' },
{ label: 'Krio', id: 'kri' },
{ label: 'Kurdish', id: 'ku' },
{ label: 'Kurdish (Sorani)', id: 'ckb' },
{ label: 'Kyrgyz', id: 'ky' },
{ label: 'Lao', id: 'lo' },
{ label: 'Latin', id: 'la' },
{ label: 'Latvian', id: 'lv' },
{ label: 'Lingala', id: 'ln' },
{ label: 'Lithuanian', id: 'lt' },
{ label: 'Luganda', id: 'lg' },
{ label: 'Luxembourgish', id: 'lb' },
{ label: 'Macedonian', id: 'mk' },
{ label: 'Maithili', id: 'mai' },
{ label: 'Malagasy', id: 'mg' },
{ label: 'Malay', id: 'ms' },
{ label: 'Malayalam', id: 'ml' },
{ label: 'Maltese', id: 'mt' },
{ label: 'Maori', id: 'mi' },
{ label: 'Marathi', id: 'mr' },
{ label: 'Meiteilon (Manipuri)', id: 'mni-Mtei' },
{ label: 'Mizo', id: 'lus' },
{ label: 'Mongolian', id: 'mn' },
{ label: 'Myanmar (Burmese)', id: 'my' },
{ label: 'Nepali', id: 'ne' },
{ label: 'Norwegian', id: 'no' },
{ label: 'Nyanja (Chichewa)', id: 'ny' },
{ label: 'Odia (Oriya)', id: 'or' },
{ label: 'Oromo', id: 'om' },
{ label: 'Pashto', id: 'ps' },
{ label: 'Persian', id: 'fa' },
{ label: 'Polish', id: 'pl' },
{ label: 'Portuguese', id: 'pt' },
{ label: 'Punjabi', id: 'pa' },
{ label: 'Quechua', id: 'qu' },
{ label: 'Romanian', id: 'ro' },
{ label: 'Russian', id: 'ru' },
{ label: 'Samoan', id: 'sm' },
{ label: 'Sanskrit', id: 'sa' },
{ label: 'Scots Gaelic', id: 'gd' },
{ label: 'Sepedi', id: 'nso' },
{ label: 'Serbian', id: 'sr' },
{ label: 'Sesotho', id: 'st' },
{ label: 'Shona', id: 'sn' },
{ label: 'Sindhi', id: 'sd' },
{ label: 'Sinhala', id: 'si' },
{ label: 'Slovak', id: 'sk' },
{ label: 'Slovenian', id: 'sl' },
{ label: 'Somali', id: 'so' },
{ label: 'Spanish', id: 'es' },
{ label: 'Sundanese', id: 'su' },
{ label: 'Swahili', id: 'sw' },
{ label: 'Swedish', id: 'sv' },
{ label: 'Tajik', id: 'tg' },
{ label: 'Tamil', id: 'ta' },
{ label: 'Tatar', id: 'tt' },
{ label: 'Telugu', id: 'te' },
{ label: 'Thai', id: 'th' },
{ label: 'Tigrinya', id: 'ti' },
{ label: 'Tsonga', id: 'ts' },
{ label: 'Turkish', id: 'tr' },
{ label: 'Turkmen', id: 'tk' },
{ label: 'Twi (Akan)', id: 'ak' },
{ label: 'Ukrainian', id: 'uk' },
{ label: 'Urdu', id: 'ur' },
{ label: 'Uyghur', id: 'ug' },
{ label: 'Uzbek', id: 'uz' },
{ label: 'Vietnamese', id: 'vi' },
{ label: 'Welsh', id: 'cy' },
{ label: 'Xhosa', id: 'xh' },
{ label: 'Yiddish', id: 'yi' },
{ label: 'Yoruba', id: 'yo' },
{ label: 'Zulu', id: 'zu' },
] satisfies { label: string; id: string }[]
export const GoogleTranslateBlock: BlockConfig = {
type: 'google_translate',
name: 'Google Translate',
description: 'Translate text using Google Cloud Translation',
longDescription:
'Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.',
docsLink: 'https://docs.sim.ai/tools/google_translate',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleTranslateIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Translate Text', id: 'text' },
{ label: 'Detect Language', id: 'detect' },
],
value: () => 'text',
},
{
id: 'text',
title: 'Text',
type: 'long-input',
placeholder: 'Enter text...',
required: true,
},
{
id: 'target',
title: 'Target Language',
type: 'dropdown',
condition: { field: 'operation', value: 'text' },
searchable: true,
options: SUPPORTED_LANGUAGES,
value: () => 'es',
required: { field: 'operation', value: 'text' },
},
{
id: 'source',
title: 'Source Language',
type: 'dropdown',
condition: { field: 'operation', value: 'text' },
searchable: true,
options: [{ label: 'Auto-detect', id: '' }, ...SUPPORTED_LANGUAGES],
value: () => '',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Google Cloud API key',
password: true,
required: true,
},
],
tools: {
access: ['google_translate_text', 'google_translate_detect'],
config: {
tool: (params) => `google_translate_${params.operation}`,
},
},
inputs: {
text: { type: 'string', description: 'Text to translate or detect language of' },
target: { type: 'string', description: 'Target language code' },
source: { type: 'string', description: 'Source language code (optional, auto-detected)' },
apiKey: { type: 'string', description: 'Google Cloud API key' },
},
outputs: {
translatedText: { type: 'string', description: 'Translated text' },
detectedSourceLanguage: { type: 'string', description: 'Detected source language code' },
language: { type: 'string', description: 'Detected language code' },
confidence: { type: 'number', description: 'Detection confidence score' },
},
}

View File

@@ -23,6 +23,7 @@ import { ConditionBlock } from '@/blocks/blocks/condition'
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor'
import { DatadogBlock } from '@/blocks/blocks/datadog'
import { DevinBlock } from '@/blocks/blocks/devin'
import { DiscordBlock } from '@/blocks/blocks/discord'
import { DropboxBlock } from '@/blocks/blocks/dropbox'
import { DSPyBlock } from '@/blocks/blocks/dspy'
@@ -43,6 +44,7 @@ import { GitLabBlock } from '@/blocks/blocks/gitlab'
import { GmailBlock, GmailV2Block } from '@/blocks/blocks/gmail'
import { GongBlock } from '@/blocks/blocks/gong'
import { GoogleSearchBlock } from '@/blocks/blocks/google'
import { GoogleBigQueryBlock } from '@/blocks/blocks/google_bigquery'
import { GoogleBooksBlock } from '@/blocks/blocks/google_books'
import { GoogleCalendarBlock, GoogleCalendarV2Block } from '@/blocks/blocks/google_calendar'
import { GoogleDocsBlock } from '@/blocks/blocks/google_docs'
@@ -52,6 +54,8 @@ import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups'
import { GoogleMapsBlock } from '@/blocks/blocks/google_maps'
import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets'
import { GoogleSlidesBlock, GoogleSlidesV2Block } from '@/blocks/blocks/google_slides'
import { GoogleTasksBlock } from '@/blocks/blocks/google_tasks'
import { GoogleTranslateBlock } from '@/blocks/blocks/google_translate'
import { GoogleVaultBlock } from '@/blocks/blocks/google_vault'
import { GrafanaBlock } from '@/blocks/blocks/grafana'
import { GrainBlock } from '@/blocks/blocks/grain'
@@ -203,6 +207,7 @@ export const registry: Record<string, BlockConfig> = {
cursor: CursorBlock,
cursor_v2: CursorV2Block,
datadog: DatadogBlock,
devin: DevinBlock,
discord: DiscordBlock,
dropbox: DropboxBlock,
dspy: DSPyBlock,
@@ -234,12 +239,15 @@ export const registry: Record<string, BlockConfig> = {
google_forms: GoogleFormsBlock,
google_groups: GoogleGroupsBlock,
google_maps: GoogleMapsBlock,
google_tasks: GoogleTasksBlock,
google_translate: GoogleTranslateBlock,
gong: GongBlock,
google_search: GoogleSearchBlock,
google_sheets: GoogleSheetsBlock,
google_sheets_v2: GoogleSheetsV2Block,
google_slides: GoogleSlidesBlock,
google_slides_v2: GoogleSlidesV2Block,
google_bigquery: GoogleBigQueryBlock,
google_vault: GoogleVaultBlock,
grafana: GrafanaBlock,
grain: GrainBlock,

View File

@@ -939,6 +939,25 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DevinIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 500 500' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M59.29,209.39l48.87,28.21c1.75,1.01,3.71,1.51,5.67,1.51c1.95,0,3.92-0.52,5.67-1.51l48.87-28.21c0,0,0.14-0.11,0.2-0.16c0.74-0.45,1.44-0.99,2.07-1.6c0.09-0.09,0.18-0.2,0.27-0.29c0.54-0.58,1.03-1.21,1.44-1.89c0.06-0.11,0.16-0.2,0.2-0.32c0.43-0.74,0.74-1.53,0.99-2.37c0.05-0.18,0.09-0.36,0.14-0.54c0.2-0.86,0.36-1.74,0.36-2.66v-28.21c0-10.89,5.87-21.03,15.3-26.48c9.42-5.45,21.15-5.44,30.59,0l24.43,14.11c0.79,0.45,1.62,0.77,2.47,1.01c0.18,0.05,0.37,0.11,0.54,0.16c0.83,0.2,1.69,0.32,2.54,0.34c0.05,0,0.09,0,0.11,0c0.09,0,0.18-0.05,0.26-0.05c0.79,0,1.58-0.11,2.34-0.32c0.14-0.03,0.27-0.05,0.4-0.09c0.83-0.23,1.64-0.57,2.41-0.99c0.06-0.05,0.16-0.05,0.23-0.09l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81V64.52c0-4.05-2.16-7.78-5.67-9.81l-48.91-28.19c-3.51-2.03-7.81-2.03-11.32,0l-48.87,28.21c0,0-0.14,0.11-0.2,0.16c-0.74,0.45-1.44,0.99-2.07,1.6c-0.09,0.09-0.18,0.2-0.27,0.29c-0.54,0.58-1.03,1.21-1.44,1.89c-0.06,0.11-0.16,0.2-0.2,0.31c-0.43,0.74-0.74,1.53-0.99,2.37c-0.05,0.18-0.09,0.36-0.14,0.54c-0.2,0.86-0.36,1.74-0.36,2.66v28.21c0,10.89-5.87,21.03-15.3,26.5c-9.42,5.44-21.15,5.44-30.59,0l-24.42-14.1c-0.79-0.45-1.63-0.77-2.47-1.01c-0.18-0.05-0.36-0.11-0.54-0.16c-0.84-0.2-1.69-0.31-2.55-0.34c-0.14,0-0.25,0-0.38,0c-0.81,0-1.6,0.11-2.37,0.31c-0.14,0.02-0.25,0.05-0.38,0.09c-0.82,0.23-1.63,0.57-2.4,1c-0.06,0.05-0.16,0.05-0.23,0.09l-48.84,28.24c-3.51,2.03-5.67,5.76-5.67,9.81v56.42c0,4.05,2.16,7.78,5.67,9.81C59.29,209.41,59.29,209.39,59.29,209.39z'
fill='#2A6DCE'
/>
<path
d='M325.46,223.49c9.42-5.44,21.15-5.44,30.59,0l24.43,14.11c0.79,0.45,1.62,0.77,2.47,1.01c0.18,0.05,0.36,0.11,0.54,0.16c0.83,0.2,1.69,0.31,2.54,0.34c0.05,0,0.09,0,0.11,0c0.09,0,0.18-0.03,0.26-0.05c0.79,0,1.58-0.11,2.34-0.31c0.14-0.03,0.27-0.05,0.4-0.09c0.83-0.23,1.62-0.57,2.41-0.99c0.06-0.05,0.16-0.05,0.25-0.09l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81l-48.84-28.22c-3.51-2.03-7.81-2.03-11.32,0l-48.87,28.21c0,0-0.14,0.11-0.2,0.16c-0.74,0.45-1.44,0.99-2.07,1.6c-0.09,0.09-0.18,0.2-0.26,0.29c-0.54,0.58-1.03,1.21-1.44,1.89c-0.06,0.11-0.16,0.2-0.2,0.32c-0.43,0.74-0.74,1.53-0.99,2.37c-0.05,0.18-0.09,0.36-0.14,0.54c-0.2,0.86-0.36,1.74-0.36,2.66v28.21c0,10.89-5.87,21.03-15.3,26.5c-9.42,5.44-21.15,5.44-30.59,0l-24.43-14.11c-0.79-0.45-1.62-0.77-2.47-1.01c-0.18-0.05-0.36-0.11-0.54-0.16c-0.83-0.2-1.69-0.32-2.54-0.34c-0.14,0-0.25,0-0.38,0c-0.81,0-1.6,0.11-2.37,0.32c-0.14,0.03-0.25,0.05-0.38,0.09c-0.83,0.23-1.64,0.57-2.41,0.99c-0.06,0.05-0.16,0.05-0.23,0.09l-48.87,28.21c-3.51,2.03-5.67,5.76-5.67,9.81v56.43c0,4.05,2.16,7.78,5.67,9.81l48.87,28.21c0,0,0.16,0.05,0.23,0.09c0.77,0.43,1.58,0.77,2.41,0.99c0.14,0.05,0.27,0.05,0.4,0.09c0.77,0.18,1.55,0.29,2.34,0.32c0.09,0,0.18,0.05,0.27,0.05c0.05,0,0.09,0,0.11,0c0.86,0,1.69-0.14,2.54-0.34c0.18-0.05,0.36-0.09,0.54-0.16c0.86-0.25,1.69-0.57,2.47-1.01l24.43-14.11c9.42-5.44,21.15-5.44,30.59,0c9.42,5.44,15.3,15.59,15.3,26.48v28.21c0,0.92,0.14,1.8,0.36,2.66c0.05,0.18,0.09,0.36,0.14,0.54c0.25,0.83,0.56,1.62,0.99,2.37c0.06,0.11,0.14,0.2,0.2,0.31c0.4,0.68,0.9,1.31,1.44,1.89c0.09,0.09,0.18,0.2,0.26,0.29c0.61,0.6,1.31,1.12,2.07,1.6c0.06,0.05,0.11,0.11,0.2,0.16l48.87,28.21c1.75,1.01,3.72,1.51,5.67,1.51s3.92-0.52,5.67-1.51l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81l-48.87-28.21c0,0-0.16-0.05-0.23-0.09c-0.77-0.43-1.58-0.77-2.41-0.99c-0.14-0.05-0.25-0.05-0.38-0.09c-0.79-0.18-1.57-0.29-2.38-0.32c-0.11,0-0.25,0-0.36,0c-0.86,0-1.71,0.14-2.54,0.34c-0.18,0.05-0.34,0.09-0.52,0.16c-0.86,0.25-1.69,0.57-2.47,1.01l-24.43,14.11c-9.42,5.44-21.15,5.44-30.58,0c-9.42-5.44-15.3-15.59-15.3-26.5c0-10.91,5.87-21.03,15.3-26.48C325.55,223.49,325.46,223.49,325.46,223.49z'
fill='#1DC19C'
/>
<path
d='M304.5,369.22l-48.87-28.21c0,0-0.16-0.05-0.23-0.09c-0.77-0.43-1.57-0.77-2.41-0.99c-0.14-0.05-0.27-0.05-0.4-0.09c-0.79-0.18-1.57-0.29-2.37-0.32c-0.14,0-0.25,0-0.38,0c-0.86,0-1.71,0.14-2.54,0.34c-0.18,0.05-0.34,0.09-0.52,0.16c-0.86,0.25-1.69,0.57-2.47,1.01l-24.43,14.11c-9.42,5.44-21.15,5.44-30.58,0c-9.42-5.44-15.3-15.59-15.3-26.5v-28.22c0-0.92-0.14-1.8-0.36-2.66c-0.05-0.18-0.09-0.36-0.14-0.54c-0.25-0.83-0.57-1.62-0.99-2.37c-0.06-0.11-0.14-0.2-0.2-0.32c-0.4-0.68-0.9-1.31-1.44-1.89c-0.09-0.09-0.18-0.2-0.27-0.29c-0.6-0.6-1.31-1.12-2.07-1.6c-0.06-0.05-0.11-0.11-0.2-0.16l-48.87-28.21c-3.51-2.03-7.81-2.03-11.32,0L59.28,290.6c-3.51,2.03-5.67,5.76-5.67,9.81v56.43c0,4.05,2.16,7.78,5.67,9.81l48.87,28.21c0,0,0.16,0.06,0.23,0.09c0.77,0.43,1.55,0.77,2.38,0.99c0.14,0.05,0.27,0.06,0.4,0.09c0.77,0.18,1.55,0.29,2.34,0.32c0.09,0,0.18,0.05,0.29,0.05c0.05,0,0.09,0,0.14,0c0.86,0,1.69-0.14,2.52-0.34c0.18-0.05,0.36-0.09,0.54-0.16c0.86-0.25,1.69-0.57,2.47-1.01l24.43-14.11c9.42-5.44,21.15-5.44,30.59,0c9.42,5.44,15.3,15.59,15.3,26.48v28.21c0,0.92,0.14,1.8,0.36,2.66c0.05,0.18,0.09,0.36,0.14,0.54c0.25,0.83,0.57,1.62,0.99,2.37c0.06,0.11,0.14,0.2,0.2,0.32c0.4,0.68,0.9,1.31,1.44,1.89c0.09,0.09,0.18,0.2,0.27,0.29c0.61,0.61,1.31,1.12,2.07,1.6c0.06,0.05,0.11,0.11,0.2,0.16l48.87,28.21c1.75,1.01,3.71,1.51,5.67,1.51c1.96,0,3.92-0.52,5.67-1.51l48.87-28.21c3.51-2.03,5.67-5.76,5.67-9.81v-56.43c0-4.05-2.16-7.78-5.67-9.81L304.5,369.22z'
fill='#1796E2'
/>
</svg>
)
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1302,6 +1321,21 @@ export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTasksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 527.1 500' xmlns='http://www.w3.org/2000/svg'>
<polygon
fill='#0066DA'
points='410.4,58.3 368.8,81.2 348.2,120.6 368.8,168.8 407.8,211 450,187.5 475.9,142.8 450,87.5'
/>
<path
fill='#2684FC'
d='M249.3,219.4l98.9-98.9c29.1,22.1,50.5,53.8,59.6,90.4L272.1,346.7c-12.2,12.2-32,12.2-44.2,0l-91.5-91.5 c-9.8-9.8-9.8-25.6,0-35.3l39-39c9.8-9.8,25.6-9.8,35.3,0L249.3,219.4z M519.8,63.6l-39.7-39.7c-9.7-9.7-25.6-9.7-35.3,0 l-34.4,34.4c27.5,23,49.9,51.8,65.5,84.5l43.9-43.9C529.6,89.2,529.6,73.3,519.8,63.6z M412.5,250c0,89.8-72.8,162.5-162.5,162.5 S87.5,339.8,87.5,250S160.2,87.5,250,87.5c36.9,0,70.9,12.3,98.2,33.1l62.2-62.2C367,21.9,311.1,0,250,0C111.9,0,0,111.9,0,250 s111.9,250,250,250s250-111.9,250-250c0-38.3-8.7-74.7-24.1-107.2L407.8,211C410.8,223.5,412.5,236.6,412.5,250z'
/>
</svg>
)
}
export function SupabaseIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradient0 = `supabase_paint0_${id}`
@@ -3430,6 +3464,23 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<path
d='M14.48 58.196L.558 34.082c-.744-1.288-.744-2.876 0-4.164L14.48 5.805c.743-1.287 2.115-2.08 3.6-2.082h27.857c1.48.007 2.845.8 3.585 2.082l13.92 24.113c.744 1.288.744 2.876 0 4.164L49.52 58.196c-.743 1.287-2.115 2.08-3.6 2.082H18.07c-1.483-.005-2.85-.798-3.593-2.082z'
fill='#4386fa'
/>
<path
d='M40.697 24.235s3.87 9.283-1.406 14.545-14.883 1.894-14.883 1.894L43.95 60.27h1.984c1.486-.002 2.858-.796 3.6-2.082L58.75 42.23z'
opacity='.1'
/>
<path
d='M45.267 43.23L41 38.953a.67.67 0 0 0-.158-.12 11.63 11.63 0 1 0-2.032 2.037.67.67 0 0 0 .113.15l4.277 4.277a.67.67 0 0 0 .947 0l1.12-1.12a.67.67 0 0 0 0-.947zM31.64 40.464a8.75 8.75 0 1 1 8.749-8.749 8.75 8.75 0 0 1-8.749 8.749zm-5.593-9.216v3.616c.557.983 1.363 1.803 2.338 2.375v-6.013zm4.375-2.998v9.772a6.45 6.45 0 0 0 2.338 0V28.25zm6.764 6.606v-2.142H34.85v4.5a6.43 6.43 0 0 0 2.338-2.368z'
fill='#fff'
/>
</svg>
)
export const GoogleVaultIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 82 82'>
<path
@@ -5445,6 +5496,34 @@ export function GoogleMapsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function GoogleTranslateIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 998.1 998.3'>
<path
fill='#DBDBDB'
d='M931.7 998.3c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4H283.6l260.1 797.9h388z'
/>
<path
fill='#DCDCDC'
d='M931.7 230.4c9.7 0 18.9 3.8 25.8 10.6 6.8 6.7 10.6 15.5 10.6 24.8v667.1c0 9.3-3.7 18.1-10.6 24.8-6.9 6.8-16.1 10.6-25.8 10.6H565.5L324.9 230.4h606.8m0-30H283.6l260.1 797.9h388c36.5 0 66.4-29.4 66.4-65.4V265.8c0-36-29.9-65.4-66.4-65.4z'
/>
<polygon fill='#4352B8' points='482.3,809.8 543.7,998.3 714.4,809.8' />
<path
fill='#607988'
d='M936.1 476.1V437H747.6v-63.2h-61.2V437H566.1v39.1h239.4c-12.8 45.1-41.1 87.7-68.7 120.8-48.9-57.9-49.1-76.7-49.1-76.7h-50.8s2.1 28.2 70.7 108.6c-22.3 22.8-39.2 36.3-39.2 36.3l15.6 48.8s23.6-20.3 53.1-51.6c29.6 32.1 67.8 70.7 117.2 116.7l32.1-32.1c-52.9-48-91.7-86.1-120.2-116.7 38.2-45.2 77-102.1 85.2-154.2H936v.1z'
/>
<path
fill='#4285F4'
d='M66.4 0C29.9 0 0 29.9 0 66.5v677c0 36.5 29.9 66.4 66.4 66.4h648.1L454.4 0h-388z'
/>
<path
fill='#EEEEEE'
d='M371.4 430.6c-2.5 30.3-28.4 75.2-91.1 75.2-54.3 0-98.3-44.9-98.3-100.2s44-100.2 98.3-100.2c30.9 0 51.5 13.4 63.3 24.3l41.2-39.6c-27.1-25-62.4-40.6-104.5-40.6-86.1 0-156 69.9-156 156s69.9 156 156 156c90.2 0 149.8-63.3 149.8-152.6 0-12.8-1.6-22.2-3.7-31.8h-146v53.4l91 .1z'
/>
</svg>
)
}
export function DsPyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='30 28 185 175' fill='none'>

View File

@@ -158,7 +158,6 @@ export const DEFAULTS = {
MAX_LOOP_ITERATIONS: 1000,
MAX_FOREACH_ITEMS: 1000,
MAX_PARALLEL_BRANCHES: 20,
MAX_WORKFLOW_DEPTH: 10,
MAX_SSE_CHILD_DEPTH: 3,
EXECUTION_TIME: 0,
TOKENS: {

View File

@@ -80,7 +80,10 @@ export class BlockExecutor {
const startTime = performance.now()
let resolvedInputs: Record<string, any> = {}
const nodeMetadata = this.buildNodeMetadata(node)
const nodeMetadata = {
...this.buildNodeMetadata(node),
executionOrder: blockLog?.executionOrder,
}
let cleanupSelfReference: (() => void) | undefined
if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) {

View File

@@ -89,7 +89,8 @@ export interface ExecutionCallbacks {
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
iterationContext?: IterationContext,
executionOrder?: number
) => void
}
@@ -155,7 +156,8 @@ export interface ContextExtensions {
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
iterationContext?: IterationContext,
executionOrder?: number
) => void
/**

View File

@@ -123,7 +123,6 @@ describe('AgentBlockHandler', () => {
let handler: AgentBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let originalPromiseAll: any
beforeEach(() => {
handler = new AgentBlockHandler()
@@ -135,8 +134,6 @@ describe('AgentBlockHandler', () => {
configurable: true,
})
originalPromiseAll = Promise.all
mockBlock = {
id: 'test-agent-block',
metadata: { id: BlockType.AGENT, name: 'Test Agent' },
@@ -209,8 +206,6 @@ describe('AgentBlockHandler', () => {
})
afterEach(() => {
Promise.all = originalPromiseAll
try {
Object.defineProperty(global, 'window', {
value: undefined,
@@ -271,38 +266,7 @@ describe('AgentBlockHandler', () => {
expect(result).toEqual(expectedOutput)
})
it('should preserve executeFunction for custom tools with different usageControl settings', async () => {
let capturedTools: any[] = []
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
const result = originalPromiseAll.call(Promise, promises)
result.then((tools: any[]) => {
if (tools?.length) {
capturedTools = tools.filter((t) => t !== null)
}
})
return result
})
mockExecuteProviderRequest.mockResolvedValueOnce({
content: 'Using tools to respond',
model: 'mock-model',
tokens: { input: 10, output: 20, total: 30 },
toolCalls: [
{
name: 'auto_tool',
arguments: { input: 'test input for auto tool' },
},
{
name: 'force_tool',
arguments: { input: 'test input for force tool' },
},
],
timing: { total: 100 },
})
it('should preserve usageControl for custom tools and filter out "none"', async () => {
const inputs = {
model: 'gpt-4o',
userPrompt: 'Test custom tools with different usageControl settings',
@@ -372,13 +336,14 @@ describe('AgentBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(Promise.all).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools
expect(capturedTools.length).toBe(2)
expect(tools.length).toBe(2)
const autoTool = capturedTools.find((t) => t.name === 'auto_tool')
const forceTool = capturedTools.find((t) => t.name === 'force_tool')
const noneTool = capturedTools.find((t) => t.name === 'none_tool')
const autoTool = tools.find((t: any) => t.name === 'auto_tool')
const forceTool = tools.find((t: any) => t.name === 'force_tool')
const noneTool = tools.find((t: any) => t.name === 'none_tool')
expect(autoTool).toBeDefined()
expect(forceTool).toBeDefined()
@@ -386,37 +351,6 @@ describe('AgentBlockHandler', () => {
expect(autoTool.usageControl).toBe('auto')
expect(forceTool.usageControl).toBe('force')
expect(typeof autoTool.executeFunction).toBe('function')
expect(typeof forceTool.executeFunction).toBe('function')
await autoTool.executeFunction({ input: 'test input' })
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: 'return { result: "auto tool executed", input }',
input: 'test input',
}),
false, // skipPostProcess
expect.any(Object) // execution context
)
await forceTool.executeFunction({ input: 'another test' })
expect(mockExecuteTool).toHaveBeenNthCalledWith(
2, // Check the 2nd call
'function_execute',
expect.objectContaining({
code: 'return { result: "force tool executed", input }',
input: 'another test',
}),
false, // skipPostProcess
expect.any(Object) // execution context
)
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const requestBody = providerCall[1]
expect(requestBody.tools.length).toBe(2)
})
it('should filter out tools with usageControl set to "none"', async () => {
@@ -1763,6 +1697,52 @@ describe('AgentBlockHandler', () => {
expect(providerCallArgs[1].tools[0].name).toBe('search_files')
})
it('should pass callChain to executeProviderRequest for MCP cycle detection', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
)
const inputs = {
model: 'gpt-4o',
userPrompt: 'Search for files',
apiKey: 'test-api-key',
tools: [
{
type: 'mcp',
title: 'search_files',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
params: {
serverId: 'mcp-search-server',
toolName: 'search_files',
serverName: 'search',
},
usageControl: 'auto' as const,
},
],
}
const contextWithCallChain = {
...mockContext,
workspaceId: 'test-workspace-123',
workflowId: 'test-workflow-456',
callChain: ['wf-parent', 'test-workflow-456'],
}
mockGetProviderFromModel.mockReturnValue('openai')
await handler.execute(contextWithCallChain, mockBlock, inputs)
expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1]
expect(providerCallArgs.callChain).toEqual(['wf-parent', 'test-workflow-456'])
})
it('should handle multiple MCP tools from the same server efficiently', async () => {
const fetchCalls: any[] = []
@@ -2139,21 +2119,10 @@ describe('AgentBlockHandler', () => {
expect(tools.length).toBe(0)
})
it('should use DB code for executeFunction when customToolId resolves', async () => {
it('should use DB schema when customToolId resolves', async () => {
const toolId = 'custom-tool-123'
mockFetchForCustomTool(toolId)
let capturedTools: any[] = []
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
const result = originalPromiseAll.call(Promise, promises)
result.then((tools: any[]) => {
if (tools?.length) {
capturedTools = tools.filter((t) => t !== null)
}
})
return result
})
const inputs = {
model: 'gpt-4o',
userPrompt: 'Format a report',
@@ -2174,19 +2143,12 @@ describe('AgentBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(capturedTools.length).toBe(1)
expect(typeof capturedTools[0].executeFunction).toBe('function')
expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
const tools = providerCall[1].tools
await capturedTools[0].executeFunction({ title: 'Q1', format: 'pdf' })
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: dbCode,
}),
false,
expect.any(Object)
)
expect(tools.length).toBe(1)
expect(tools[0].name).toBe('formatReport')
})
it('should not fetch from DB when no customToolId is present', async () => {

View File

@@ -1,9 +1,8 @@
import { db } from '@sim/db'
import { account, mcpServers } from '@sim/db/schema'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import {
@@ -30,10 +29,10 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool, getToolAsync } from '@/tools/utils'
const logger = createLogger('AgentBlockHandler')
@@ -276,14 +275,12 @@ export class AgentBlockHandler implements BlockHandler {
const userProvidedParams = tool.params || {}
let schema = tool.schema
let code = tool.code
let title = tool.title
if (tool.customToolId) {
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
if (resolved) {
schema = resolved.schema
code = resolved.code
title = resolved.title
} else if (!schema) {
logger.error(`Custom tool not found: ${tool.customToolId}`)
@@ -296,7 +293,7 @@ export class AgentBlockHandler implements BlockHandler {
return null
}
const { filterSchemaForLLM, mergeToolParameters } = await import('@/tools/params')
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams)
@@ -313,43 +310,6 @@ export class AgentBlockHandler implements BlockHandler {
usageControl: tool.usageControl || 'auto',
}
if (code) {
base.executeFunction = async (callParams: Record<string, any>) => {
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
{
code,
...mergedParams,
timeout: tool.timeout ?? AGENT.DEFAULT_FUNCTION_TIMEOUT,
envVars: ctx.environmentVariables || {},
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
blockOutputSchemas,
isCustomTool: true,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
false,
ctx
)
if (!result.success) {
throw new Error(result.error || 'Function execution failed')
}
return result.output
}
}
return base
}
@@ -359,7 +319,7 @@ export class AgentBlockHandler implements BlockHandler {
private async fetchCustomToolById(
ctx: ExecutionContext,
customToolId: string
): Promise<{ schema: any; code: string; title: string } | null> {
): Promise<{ schema: any; title: string } | null> {
if (typeof window !== 'undefined') {
try {
const { getCustomTool } = await import('@/hooks/queries/custom-tools')
@@ -367,7 +327,6 @@ export class AgentBlockHandler implements BlockHandler {
if (tool) {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
}
@@ -416,7 +375,6 @@ export class AgentBlockHandler implements BlockHandler {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
} catch (error) {
@@ -481,65 +439,15 @@ export class AgentBlockHandler implements BlockHandler {
tool: ToolInput
): Promise<any> {
const { serverId, toolName, serverName, ...userProvidedParams } = tool.params || {}
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(
tool.schema || { type: 'object', properties: {} },
userProvidedParams
)
const toolId = createMcpToolId(serverId, toolName)
return {
id: toolId,
name: toolName,
return this.buildMcpTool({
serverId,
toolName,
description:
tool.schema?.description || `MCP tool ${toolName} from ${serverName || serverId}`,
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const execParams: Record<string, string> = {}
if (ctx.userId) execParams.userId = ctx.userId
const execUrl = buildAPIUrl('/api/mcp/tools/execute', execParams)
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: tool.schema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp',
serverId,
serverName: serverName || serverId,
toolName,
},
}
},
}
schema: tool.schema || { type: 'object', properties: {} },
userProvidedParams,
usageControl: tool.usageControl,
})
}
/**
@@ -668,63 +576,35 @@ export class AgentBlockHandler implements BlockHandler {
serverId: string
): Promise<any> {
const { toolName, ...userProvidedParams } = tool.params || {}
return this.buildMcpTool({
serverId,
toolName,
description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`,
schema: mcpTool.inputSchema || { type: 'object', properties: {} },
userProvidedParams,
usageControl: tool.usageControl,
})
}
private async buildMcpTool(config: {
serverId: string
toolName: string
description: string
schema: any
userProvidedParams: Record<string, any>
usageControl?: string
}): Promise<any> {
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(
mcpTool.inputSchema || { type: 'object', properties: {} },
userProvidedParams
)
const toolId = createMcpToolId(serverId, toolName)
const filteredSchema = filterSchemaForLLM(config.schema, config.userProvidedParams)
const toolId = createMcpToolId(config.serverId, config.toolName)
return {
id: toolId,
name: toolName,
description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`,
name: config.toolName,
description: config.description,
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const discoverExecParams: Record<string, string> = {}
if (ctx.userId) discoverExecParams.userId = ctx.userId
const execUrl = buildAPIUrl('/api/mcp/tools/execute', discoverExecParams)
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: mcpTool.inputSchema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp',
serverId,
serverName: mcpTool.serverName,
toolName,
},
}
},
params: config.userProvidedParams,
usageControl: config.usageControl || 'auto',
}
}
@@ -1048,9 +928,9 @@ export class AgentBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = providerRequest.apiKey
if (providerId === 'vertex' && providerRequest.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(
finalApiKey = await resolveVertexCredential(
providerRequest.vertexCredential,
ctx.workflowId
'vertex-agent'
)
}
@@ -1082,6 +962,7 @@ export class AgentBlockHandler implements BlockHandler {
blockData,
blockNameMapping,
isDeployedContext: ctx.isDeployedContext,
callChain: ctx.callChain,
reasoningEffort: providerRequest.reasoningEffort,
verbosity: providerRequest.verbosity,
thinkingLevel: providerRequest.thinkingLevel,
@@ -1096,37 +977,6 @@ export class AgentBlockHandler implements BlockHandler {
}
}
/**
* Resolves a Vertex AI OAuth credential to an access token
*/
private async resolveVertexCredential(credentialId: string, workflowId: string): Promise<string> {
const requestId = `vertex-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
}
private handleExecutionError(
error: any,
startTime: number,
@@ -1310,7 +1160,7 @@ export class AgentBlockHandler implements BlockHandler {
},
toolCalls: {
list: result.toolCalls?.map(this.formatToolCall.bind(this)) || [],
count: result.toolCalls?.length || DEFAULTS.EXECUTION_TIME,
count: result.toolCalls?.length ?? 0,
},
providerTiming: result.timing,
cost: result.cost,

View File

@@ -1,14 +1,11 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -44,7 +41,10 @@ export class EvaluatorBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = evaluatorConfig.apiKey
if (providerId === 'vertex' && evaluatorConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(evaluatorConfig.vertexCredential)
finalApiKey = await resolveVertexCredential(
evaluatorConfig.vertexCredential,
'vertex-evaluator'
)
}
const processedContent = this.processContent(inputs.content)
@@ -234,7 +234,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
if (Object.keys(parsedContent).length === 0) {
validMetrics.forEach((metric: any) => {
if (metric?.name) {
metricScores[metric.name.toLowerCase()] = DEFAULTS.EXECUTION_TIME
metricScores[metric.name.toLowerCase()] = 0
}
})
return metricScores
@@ -273,37 +273,6 @@ export class EvaluatorBlockHandler implements BlockHandler {
}
logger.warn(`Metric "${metricName}" not found in LLM response`)
return DEFAULTS.EXECUTION_TIME
}
/**
* Resolves a Vertex AI OAuth credential to an access token
*/
private async resolveVertexCredential(credentialId: string): Promise<string> {
const requestId = `vertex-evaluator-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
return 0
}
}

View File

@@ -9,7 +9,6 @@ import {
HTTP,
normalizeName,
PAUSE_RESUME,
REFERENCE,
} from '@/executor/constants'
import {
generatePauseContextId,
@@ -17,6 +16,7 @@ import {
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -265,7 +265,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
@@ -296,7 +296,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
}
}
const value = this.convertPropertyValue(prop)
const value = convertPropertyValue(prop)
entries.push({
name: path,
@@ -352,140 +352,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
.filter((field): field is NormalizedInputField => field !== null)
}
private convertBuilderDataToJson(builderData: JSONProperty[]): any {
if (!Array.isArray(builderData)) {
return {}
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
const value = this.convertPropertyValue(prop)
result[prop.name] = value
}
return result
}
static convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
if (!Array.isArray(builderData) || builderData.length === 0) {
return '{\n \n}'
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
result[prop.name] = prop.value
}
let jsonString = JSON.stringify(result, null, 2)
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
return jsonString
}
private convertPropertyValue(prop: JSONProperty): any {
switch (prop.type) {
case 'object':
return this.convertObjectValue(prop.value)
case 'array':
return this.convertArrayValue(prop.value)
case 'number':
return this.convertNumberValue(prop.value)
case 'boolean':
return this.convertBooleanValue(prop.value)
case 'files':
return prop.value
default:
return prop.value
}
}
private convertObjectValue(value: any): any {
if (Array.isArray(value)) {
return this.convertBuilderDataToJson(value)
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
return this.tryParseJson(value, value)
}
return value
}
private convertArrayValue(value: any): any {
if (Array.isArray(value)) {
return value.map((item: any) => this.convertArrayItem(item))
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
const parsed = this.tryParseJson(value, value)
return Array.isArray(parsed) ? parsed : value
}
return value
}
private convertArrayItem(item: any): any {
if (typeof item !== 'object' || !item.type) {
return item
}
if (item.type === 'object' && Array.isArray(item.value)) {
return this.convertBuilderDataToJson(item.value)
}
if (item.type === 'array' && Array.isArray(item.value)) {
return item.value.map((subItem: any) =>
typeof subItem === 'object' && subItem.type ? subItem.value : subItem
)
}
return item.value
}
private convertNumberValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
const numValue = Number(value)
return Number.isNaN(numValue) ? value : numValue
}
private convertBooleanValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
return value === 'true' || value === true
}
private tryParseJson(jsonString: string, fallback: any): any {
try {
return JSON.parse(jsonString)
} catch {
return fallback
}
}
private isVariableReference(value: any): boolean {
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)

View File

@@ -1,19 +1,15 @@
import { createLogger } from '@sim/logger'
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import {
convertBuilderDataToJson,
convertBuilderDataToJsonString,
} from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ResponseBlockHandler')
interface JSONProperty {
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
value: any
collapsed?: boolean
}
export class ResponseBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.RESPONSE
@@ -73,154 +69,15 @@ export class ResponseBlockHandler implements BlockHandler {
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
}
private convertBuilderDataToJson(builderData: JSONProperty[]): any {
if (!Array.isArray(builderData)) {
return {}
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
const value = this.convertPropertyValue(prop)
result[prop.name] = value
}
return result
}
static convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
if (!Array.isArray(builderData) || builderData.length === 0) {
return '{\n \n}'
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
result[prop.name] = prop.value
}
let jsonString = JSON.stringify(result, null, 2)
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
return jsonString
}
private convertPropertyValue(prop: JSONProperty): any {
switch (prop.type) {
case 'object':
return this.convertObjectValue(prop.value)
case 'array':
return this.convertArrayValue(prop.value)
case 'number':
return this.convertNumberValue(prop.value)
case 'boolean':
return this.convertBooleanValue(prop.value)
case 'files':
return prop.value
default:
return prop.value
}
}
private convertObjectValue(value: any): any {
if (Array.isArray(value)) {
return this.convertBuilderDataToJson(value)
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
return this.tryParseJson(value, value)
}
return value
}
private convertArrayValue(value: any): any {
if (Array.isArray(value)) {
return value.map((item: any) => this.convertArrayItem(item))
}
if (typeof value === 'string' && !this.isVariableReference(value)) {
const parsed = this.tryParseJson(value, value)
if (Array.isArray(parsed)) {
return parsed
}
return value
}
return value
}
private convertArrayItem(item: any): any {
if (typeof item !== 'object' || !item.type) {
return item
}
if (item.type === 'object' && Array.isArray(item.value)) {
return this.convertBuilderDataToJson(item.value)
}
if (item.type === 'array' && Array.isArray(item.value)) {
return item.value.map((subItem: any) => {
if (typeof subItem === 'object' && subItem.type) {
return subItem.value
}
return subItem
})
}
return item.value
}
private convertNumberValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
const numValue = Number(value)
if (Number.isNaN(numValue)) {
return value
}
return numValue
}
private convertBooleanValue(value: any): any {
if (this.isVariableReference(value)) {
return value
}
return value === 'true' || value === true
}
private tryParseJson(jsonString: string, fallback: any): any {
try {
return JSON.parse(jsonString)
} catch {
return fallback
}
}
private isVariableReference(value: any): boolean {
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
static convertBuilderDataToJsonString(builderData: any[]): string {
return convertBuilderDataToJsonString(builderData)
}
private parseStatus(status?: string): number {

View File

@@ -1,9 +1,5 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
@@ -16,6 +12,7 @@ import {
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -87,7 +84,7 @@ export class RouterBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
finalApiKey = await resolveVertexCredential(routerConfig.vertexCredential, 'vertex-router')
}
const providerRequest: Record<string, any> = {
@@ -217,7 +214,7 @@ export class RouterBlockHandler implements BlockHandler {
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
finalApiKey = await resolveVertexCredential(routerConfig.vertexCredential, 'vertex-router')
}
const providerRequest: Record<string, any> = {
@@ -416,35 +413,4 @@ export class RouterBlockHandler implements BlockHandler {
}
})
}
/**
* Resolves a Vertex AI OAuth credential to an access token
*/
private async resolveVertexCredential(credentialId: string): Promise<string> {
const requestId = `vertex-router-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
}
}

View File

@@ -108,18 +108,16 @@ describe('WorkflowBlockHandler', () => {
)
})
it('should enforce maximum depth limit', async () => {
it('should enforce maximum call chain depth limit', async () => {
const inputs = { workflowId: 'child-workflow-id' }
// Create a deeply nested context (simulate 11 levels deep to exceed the limit of 10)
const deepContext = {
...mockContext,
workflowId:
'level1_sub_level2_sub_level3_sub_level4_sub_level5_sub_level6_sub_level7_sub_level8_sub_level9_sub_level10_sub_level11',
callChain: Array.from({ length: 25 }, (_, i) => `wf-${i}`),
}
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
'Maximum workflow call chain depth (25) exceeded'
)
})

View File

@@ -62,6 +62,7 @@ export class WorkflowBlockHandler implements BlockHandler {
branchTotal?: number
originalBlockId?: string
isLoopNode?: boolean
executionOrder?: number
}
): Promise<BlockOutput | StreamingExecution> {
return this._executeCore(ctx, block, inputs, nodeMetadata)
@@ -79,6 +80,7 @@ export class WorkflowBlockHandler implements BlockHandler {
branchTotal?: number
originalBlockId?: string
isLoopNode?: boolean
executionOrder?: number
}
): Promise<BlockOutput | StreamingExecution> {
logger.info(`Executing workflow block: ${block.id}`)
@@ -98,13 +100,17 @@ export class WorkflowBlockHandler implements BlockHandler {
// workflow block execution, preventing cross-iteration child mixing in loop contexts.
const instanceId = crypto.randomUUID()
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
let childWorkflowSnapshotId: string | undefined
try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
throw new Error(`Maximum workflow nesting depth of ${DEFAULTS.MAX_WORKFLOW_DEPTH} exceeded`)
}
if (ctx.isDeployedContext) {
const hasActiveDeployment = await this.checkChildDeployment(workflowId)
if (!hasActiveDeployment) {
@@ -126,7 +132,7 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
`Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}`
)
let childWorkflowInput: Record<string, any> = {}
@@ -165,16 +171,12 @@ export class WorkflowBlockHandler implements BlockHandler {
const iterationContext = nodeMetadata
? this.getIterationContext(ctx, nodeMetadata)
: undefined
ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext)
}
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
ctx.onChildWorkflowInstanceReady?.(
effectiveBlockId,
instanceId,
iterationContext,
nodeMetadata?.executionOrder
)
}
const subExecutor = new Executor({
@@ -584,45 +586,6 @@ export class WorkflowBlockHandler implements BlockHandler {
return processed
}
private flattenChildWorkflowSpans(spans: TraceSpan[]): WorkflowTraceSpan[] {
const flattened: WorkflowTraceSpan[] = []
spans.forEach((span) => {
if (this.isSyntheticWorkflowWrapper(span)) {
if (span.children && Array.isArray(span.children)) {
flattened.push(...this.flattenChildWorkflowSpans(span.children))
}
return
}
const workflowSpan: WorkflowTraceSpan = {
...span,
}
if (Array.isArray(workflowSpan.children)) {
const childSpans = workflowSpan.children as TraceSpan[]
workflowSpan.children = this.flattenChildWorkflowSpans(childSpans)
}
if (workflowSpan.output && typeof workflowSpan.output === 'object') {
const { childTraceSpans: nestedChildSpans, ...outputRest } = workflowSpan.output as {
childTraceSpans?: TraceSpan[]
} & Record<string, unknown>
if (Array.isArray(nestedChildSpans) && nestedChildSpans.length > 0) {
const flattenedNestedChildren = this.flattenChildWorkflowSpans(nestedChildSpans)
workflowSpan.children = [...(workflowSpan.children || []), ...flattenedNestedChildren]
}
workflowSpan.output = outputRest
}
flattened.push(workflowSpan)
})
return flattened
}
private toExecutionResult(result: ExecutionResult | StreamingExecution): ExecutionResult {
return 'execution' in result ? result.execution : result
}

View File

@@ -264,7 +264,8 @@ export interface ExecutionContext {
onChildWorkflowInstanceReady?: (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
iterationContext?: IterationContext,
executionOrder?: number
) => void
/**
@@ -377,6 +378,7 @@ export interface BlockHandler {
branchTotal?: number
originalBlockId?: string
isLoopNode?: boolean
executionOrder?: number
}
) => Promise<BlockOutput | StreamingExecution>
}

View File

@@ -0,0 +1,149 @@
import { REFERENCE } from '@/executor/constants'
export interface JSONProperty {
id: string
name: string
type: string
value: any
collapsed?: boolean
}
/**
* Converts builder data (structured JSON properties) into a plain JSON object.
*/
export function convertBuilderDataToJson(builderData: JSONProperty[]): any {
if (!Array.isArray(builderData)) {
return {}
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
const value = convertPropertyValue(prop)
result[prop.name] = value
}
return result
}
/**
* Converts builder data into a JSON string with variable references unquoted.
*/
export function convertBuilderDataToJsonString(builderData: JSONProperty[]): string {
if (!Array.isArray(builderData) || builderData.length === 0) {
return '{\n \n}'
}
const result: any = {}
for (const prop of builderData) {
if (!prop.name || !prop.name.trim()) {
continue
}
result[prop.name] = prop.value
}
let jsonString = JSON.stringify(result, null, 2)
jsonString = jsonString.replace(/"(<[^>]+>)"/g, '$1')
return jsonString
}
export function convertPropertyValue(prop: JSONProperty): any {
switch (prop.type) {
case 'object':
return convertObjectValue(prop.value)
case 'array':
return convertArrayValue(prop.value)
case 'number':
return convertNumberValue(prop.value)
case 'boolean':
return convertBooleanValue(prop.value)
case 'files':
return prop.value
default:
return prop.value
}
}
function convertObjectValue(value: any): any {
if (Array.isArray(value)) {
return convertBuilderDataToJson(value)
}
if (typeof value === 'string' && !isVariableReference(value)) {
return tryParseJson(value, value)
}
return value
}
function convertArrayValue(value: any): any {
if (Array.isArray(value)) {
return value.map((item: any) => convertArrayItem(item))
}
if (typeof value === 'string' && !isVariableReference(value)) {
const parsed = tryParseJson(value, value)
return Array.isArray(parsed) ? parsed : value
}
return value
}
function convertArrayItem(item: any): any {
if (typeof item !== 'object' || !item.type) {
return item
}
if (item.type === 'object' && Array.isArray(item.value)) {
return convertBuilderDataToJson(item.value)
}
if (item.type === 'array' && Array.isArray(item.value)) {
return item.value.map((subItem: any) =>
typeof subItem === 'object' && subItem.type ? subItem.value : subItem
)
}
return item.value
}
function convertNumberValue(value: any): any {
if (isVariableReference(value)) {
return value
}
const numValue = Number(value)
return Number.isNaN(numValue) ? value : numValue
}
function convertBooleanValue(value: any): any {
if (isVariableReference(value)) {
return value
}
return value === 'true' || value === true
}
function tryParseJson(jsonString: string, fallback: any): any {
try {
return JSON.parse(jsonString)
} catch {
return fallback
}
}
function isVariableReference(value: any): boolean {
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
}

View File

@@ -215,5 +215,115 @@ describe('start-block utilities', () => {
expect(output.customField).toBe('defaultValue')
})
it.concurrent('preserves coerced types for unified start payload', () => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'conversation_id', type: 'number' },
{ name: 'sender', type: 'object' },
{ name: 'is_active', type: 'boolean' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'true',
},
})
expect(output.conversation_id).toBe(149)
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
expect(output.is_active).toBe(true)
})
it.concurrent(
'prefers coerced inputFormat values over duplicated top-level workflowInput keys',
() => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'conversation_id', type: 'number' },
{ name: 'sender', type: 'object' },
{ name: 'is_active', type: 'boolean' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: {
input: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'false',
},
conversation_id: '150',
sender: '{"id":99,"email":"wrong@example.com"}',
is_active: 'true',
extra: 'keep-me',
},
})
expect(output.conversation_id).toBe(149)
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
expect(output.is_active).toBe(false)
expect(output.extra).toBe('keep-me')
}
)
})
describe('EXTERNAL_TRIGGER path', () => {
it.concurrent('preserves coerced types for integration trigger payload', () => {
const block = createBlock('webhook', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'count', type: 'number' },
{ name: 'payload', type: 'object' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.EXTERNAL_TRIGGER,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: {
count: '5',
payload: '{"event":"push"}',
extra: 'untouched',
},
})
expect(output.count).toBe(5)
expect(output.payload).toEqual({ event: 'push' })
expect(output.extra).toBe('untouched')
})
})
})

View File

@@ -262,6 +262,7 @@ function buildUnifiedStartOutput(
hasStructured: boolean
): NormalizedBlockOutput {
const output: NormalizedBlockOutput = {}
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
if (hasStructured) {
for (const [key, value] of Object.entries(structuredInput)) {
@@ -272,6 +273,9 @@ function buildUnifiedStartOutput(
if (isPlainObject(workflowInput)) {
for (const [key, value] of Object.entries(workflowInput)) {
if (key === 'onUploadError') continue
// Skip keys already set by schema-coerced structuredInput to
// prevent raw workflowInput strings from overwriting typed values.
if (structuredKeys?.has(key)) continue
// Runtime values override defaults (except undefined/null which mean "not provided")
if (value !== undefined && value !== null) {
output[key] = value
@@ -384,6 +388,7 @@ function buildIntegrationTriggerOutput(
hasStructured: boolean
): NormalizedBlockOutput {
const output: NormalizedBlockOutput = {}
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
if (hasStructured) {
for (const [key, value] of Object.entries(structuredInput)) {
@@ -393,6 +398,7 @@ function buildIntegrationTriggerOutput(
if (isPlainObject(workflowInput)) {
for (const [key, value] of Object.entries(workflowInput)) {
if (structuredKeys?.has(key)) continue
if (value !== undefined && value !== null) {
output[key] = value
} else if (!Object.hasOwn(output, key)) {

View File

@@ -0,0 +1,42 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('VertexCredential')
/**
* Resolves a Vertex AI OAuth credential to an access token.
* Shared across agent, evaluator, and router handlers.
*/
export async function resolveVertexCredential(
credentialId: string,
callerLabel = 'vertex'
): Promise<string> {
const requestId = `${callerLabel}-${Date.now()}`
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')
}
logger.info(`[${requestId}] Successfully resolved Vertex AI credential`)
return accessToken
}

View File

@@ -131,6 +131,8 @@ export const AuditAction = {
WORKFLOW_DUPLICATED: 'workflow.duplicated',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated',
WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted',
WORKFLOW_LOCKED: 'workflow.locked',
WORKFLOW_UNLOCKED: 'workflow.unlocked',
WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated',
// Workspaces

View File

@@ -484,8 +484,10 @@ export const auth = betterAuth({
'google-docs',
'google-sheets',
'google-forms',
'google-bigquery',
'google-vault',
'google-groups',
'google-tasks',
'vertex-ai',
'github-repo',
'microsoft-dataverse',
@@ -1068,6 +1070,46 @@ export const auth = betterAuth({
}
},
},
{
providerId: 'google-bigquery',
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/bigquery',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
logger.error('Failed to fetch Google user info', { status: response.status })
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.sub}-${crypto.randomUUID()}`,
name: profile.name || 'Google User',
email: profile.email,
image: profile.picture || undefined,
emailVerified: profile.email_verified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Google getUserInfo', { error })
throw error
}
},
},
{
providerId: 'google-vault',
clientId: env.GOOGLE_CLIENT_ID as string,
@@ -1150,6 +1192,46 @@ export const auth = betterAuth({
},
},
{
providerId: 'google-tasks',
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/tasks',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
logger.error('Failed to fetch Google user info', { status: response.status })
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.sub}-${crypto.randomUUID()}`,
name: profile.name || 'Google User',
email: profile.email,
image: profile.picture || undefined,
emailVerified: profile.email_verified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Google getUserInfo', { error })
throw error
}
},
},
{
providerId: 'vertex-ai',
clientId: env.GOOGLE_CLIENT_ID as string,
@@ -1846,6 +1928,15 @@ export const auth = betterAuth({
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
'read:task:confluence',
'write:task:confluence',
'delete:blogpost:confluence',
'write:space:confluence',
'delete:space:confluence',
'read:space.property:confluence',
'write:space.property:confluence',
'read:space.permission:confluence',
],
responseType: 'code',
pkce: true,

View File

@@ -1039,3 +1039,74 @@ export function validateGoogleCalendarId(
return { isValid: true, sanitized: value }
}
/**
* Validates a pagination cursor token
*
* Pagination cursors are opaque tokens returned by APIs (e.g., Confluence, Jira)
* and passed back to get the next page. They are typically base64-encoded or
* URL-safe strings. This validator ensures the cursor cannot contain characters
* that could alter URL structure.
*
* @param value - The cursor token to validate
* @param paramName - Name of the parameter for error messages
* @param maxLength - Maximum length (default: 1024)
* @returns ValidationResult
*
* @example
* ```typescript
* if (cursor) {
* const result = validatePaginationCursor(cursor, 'cursor')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* }
* ```
*/
export function validatePaginationCursor(
value: string | null | undefined,
paramName = 'cursor',
maxLength = 1024
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
if (value.length > maxLength) {
logger.warn('Pagination cursor exceeds maximum length', {
paramName,
length: value.length,
maxLength,
})
return {
isValid: false,
error: `${paramName} exceeds maximum length of ${maxLength} characters`,
}
}
if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) {
logger.warn('Pagination cursor contains control characters', { paramName })
return {
isValid: false,
error: `${paramName} contains invalid characters`,
}
}
// Allow alphanumeric, base64 chars (+, /, =), and URL-safe chars (-, _, ., ~, %)
const cursorPattern = /^[A-Za-z0-9+/=\-_.~%]+$/
if (!cursorPattern.test(value)) {
logger.warn('Pagination cursor contains disallowed characters', {
paramName,
value: value.substring(0, 100),
})
return {
isValid: false,
error: `${paramName} contains invalid characters`,
}
}
return { isValid: true, sanitized: value }
}

View File

@@ -19,8 +19,8 @@ describe('call-chain', () => {
})
describe('MAX_CALL_CHAIN_DEPTH', () => {
it('equals 10', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(10)
it('equals 25', () => {
expect(MAX_CALL_CHAIN_DEPTH).toBe(25)
})
})

View File

@@ -7,7 +7,7 @@
*/
export const SIM_VIA_HEADER = 'X-Sim-Via'
export const MAX_CALL_CHAIN_DEPTH = 10
export const MAX_CALL_CHAIN_DEPTH = 25
/**
* Parses the `X-Sim-Via` header value into an ordered list of workflow IDs.

View File

@@ -170,7 +170,8 @@ class McpService {
userId: string,
serverId: string,
toolCall: McpToolCall,
workspaceId: string
workspaceId: string,
extraHeaders?: Record<string, string>
): Promise<McpToolResult> {
const requestId = generateRequestId()
const maxRetries = 2
@@ -187,6 +188,9 @@ class McpService {
}
const resolvedConfig = await this.resolveConfigEnvVars(config, userId, workspaceId)
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
}
const client = await this.createClient(resolvedConfig)
try {

View File

@@ -8,6 +8,7 @@ import {
DropboxIcon,
GithubIcon,
GmailIcon,
GoogleBigQueryIcon,
GoogleCalendarIcon,
GoogleDocsIcon,
GoogleDriveIcon,
@@ -15,6 +16,7 @@ import {
GoogleGroupsIcon,
GoogleIcon,
GoogleSheetsIcon,
GoogleTasksIcon,
HubspotIcon,
JiraIcon,
LinearIcon,
@@ -119,6 +121,22 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/calendar'],
},
'google-bigquery': {
name: 'Google BigQuery',
description: 'Query, list, and insert data in Google BigQuery.',
providerId: 'google-bigquery',
icon: GoogleBigQueryIcon,
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/bigquery'],
},
'google-tasks': {
name: 'Google Tasks',
description: 'Create, manage, and organize tasks with Google Tasks.',
providerId: 'google-tasks',
icon: GoogleTasksIcon,
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/tasks'],
},
'google-vault': {
name: 'Google Vault',
description: 'Search, export, and manage matters/holds via Google Vault.',
@@ -330,6 +348,21 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'delete:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
'read:task:confluence',
'write:task:confluence',
'write:space:confluence',
'delete:space:confluence',
'read:space.property:confluence',
'write:space.property:confluence',
'read:space.permission:confluence',
],
},
},

View File

@@ -7,6 +7,8 @@ export type OAuthProvider =
| 'google-docs'
| 'google-sheets'
| 'google-calendar'
| 'google-bigquery'
| 'google-tasks'
| 'google-vault'
| 'google-forms'
| 'google-groups'
@@ -52,6 +54,8 @@ export type OAuthService =
| 'google-docs'
| 'google-sheets'
| 'google-calendar'
| 'google-bigquery'
| 'google-tasks'
| 'google-vault'
| 'google-forms'
| 'google-groups'

View File

@@ -155,6 +155,7 @@ export interface BlockChildWorkflowStartedEvent extends BaseExecutionEvent {
childWorkflowInstanceId: string
iterationCurrent?: number
iterationContainerId?: string
executionOrder?: number
}
}
@@ -396,7 +397,8 @@ export function createSSECallbacks(options: SSECallbackOptions) {
const onChildWorkflowInstanceReady = (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
iterationContext?: IterationContext,
executionOrder?: number
) => {
sendEvent({
type: 'block:childWorkflowStarted',
@@ -410,6 +412,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
iterationCurrent: iterationContext.iterationCurrent,
iterationContainerId: iterationContext.iterationContainerId,
}),
...(executionOrder !== undefined && { executionOrder }),
},
})
}

View File

@@ -174,6 +174,7 @@ export interface ProviderRequest {
verbosity?: string
thinkingLevel?: string
isDeployedContext?: boolean
callChain?: string[]
/** Previous interaction ID for multi-turn Interactions API requests (deep research follow-ups) */
previousInteractionId?: string
abortSignal?: AbortSignal

View File

@@ -1110,6 +1110,7 @@ export function prepareToolExecution(
blockData?: Record<string, any>
blockNameMapping?: Record<string, string>
isDeployedContext?: boolean
callChain?: string[]
}
): {
toolParams: Record<string, any>
@@ -1137,6 +1138,7 @@ export function prepareToolExecution(
...(request.isDeployedContext !== undefined
? { isDeployedContext: request.isDeployedContext }
: {}),
...(request.callChain ? { callChain: request.callChain } : {}),
},
}
: {}),

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { env } from '@/lib/core/config/env'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -207,6 +208,17 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
})
// Audit workflow-level lock/unlock operations
if (
target === OPERATION_TARGETS.BLOCKS &&
op === BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED &&
userId
) {
auditWorkflowLockToggle(workflowId, userId).catch((error) => {
logger.error('Failed to audit workflow lock toggle', { error, workflowId })
})
}
const duration = Date.now() - startTime
if (duration > 100) {
logger.warn('Slow socket DB operation:', {
@@ -226,6 +238,43 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
}
/**
* Records an audit log entry when all blocks in a workflow are locked or unlocked.
* Only audits workflow-level transitions (all locked or all unlocked), not partial toggles.
*/
async function auditWorkflowLockToggle(workflowId: string, actorId: string): Promise<void> {
const [wf] = await db
.select({ name: workflow.name, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
if (!wf) return
const blocks = await db
.select({ locked: workflowBlocks.locked })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
if (blocks.length === 0) return
const allLocked = blocks.every((b) => b.locked)
const allUnlocked = blocks.every((b) => !b.locked)
// Only audit workflow-level transitions, not partial toggles
if (!allLocked && !allUnlocked) return
recordAudit({
workspaceId: wf.workspaceId,
actorId,
action: allLocked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: wf.name,
description: allLocked ? `Locked workflow "${wf.name}"` : `Unlocked workflow "${wf.name}"`,
metadata: { blockCount: blocks.length },
})
}
async function handleBlockOperationTx(
tx: any,
workflowId: string,

View File

@@ -6,7 +6,7 @@ export interface NotificationAction {
/**
* Action type identifier for handler reconstruction
*/
type: 'copilot' | 'refresh'
type: 'copilot' | 'refresh' | 'unlock-workflow'
/**
* Message or data to pass to the action handler.

View File

@@ -91,6 +91,13 @@ const matchesEntryForUpdate = (
return false
}
if (
update.childWorkflowBlockId !== undefined &&
entry.childWorkflowBlockId !== update.childWorkflowBlockId
) {
return false
}
return true
}

View File

@@ -0,0 +1,134 @@
import { SPACE_DESCRIPTION_OUTPUT_PROPERTIES, 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
spaceId: string
name: string
key: string
type: string
status: string
url: string
homepageId: string | null
description: { value: string; representation: 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 (uppercase, no spaces)',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Description for the new 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/space',
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,
cloudId: params.cloudId,
name: params.name,
key: params.key,
description: params.description,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.id ?? '',
name: data.name ?? '',
key: data.key ?? '',
type: data.type ?? '',
status: data.status ?? '',
url: data._links?.webui ?? '',
homepageId: data.homepageId ?? null,
description: data.description ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'Created space ID' },
name: { type: 'string', description: 'Space name' },
key: { type: 'string', description: 'Space key' },
type: { type: 'string', description: 'Space type' },
status: { type: 'string', description: 'Space status' },
url: { type: 'string', description: 'URL to view the space' },
homepageId: { type: 'string', description: 'Homepage ID', optional: true },
description: {
type: 'object',
description: 'Space description',
properties: SPACE_DESCRIPTION_OUTPUT_PROPERTIES,
optional: true,
},
},
}

View File

@@ -0,0 +1,118 @@
import { 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
propertyId: string
key: string
value: unknown
spaceId: string
}
}
export const confluenceCreateSpacePropertyTool: ToolConfig<
ConfluenceCreateSpacePropertyParams,
ConfluenceCreateSpacePropertyResponse
> = {
id: 'confluence_create_space_property',
name: 'Confluence Create Space Property',
description: 'Create a 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: 'Space ID to create the property on',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Property key/name',
},
value: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Property value (JSON)',
},
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,
cloudId: params.cloudId,
spaceId: params.spaceId,
action: 'create',
key: params.key,
value: params.value,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
propertyId: data.propertyId ?? '',
key: data.key ?? '',
value: data.value ?? null,
spaceId: data.spaceId ?? '',
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
propertyId: { type: 'string', description: 'Created property ID' },
key: { type: 'string', description: 'Property key' },
value: { type: 'json', description: 'Property value' },
spaceId: { type: 'string', description: 'Space ID' },
},
}

View File

@@ -0,0 +1,95 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceDeleteBlogPostParams {
accessToken: string
domain: string
blogPostId: string
cloudId?: string
}
export interface ConfluenceDeleteBlogPostResponse {
success: boolean
output: {
ts: string
blogPostId: string
deleted: boolean
}
}
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',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceDeleteBlogPostParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
blogPostId: params.blogPostId,
}),
},
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: { type: 'boolean', description: 'Deletion status' },
},
}

View File

@@ -0,0 +1,95 @@
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.',
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: '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/space',
method: 'DELETE',
headers: (params: ConfluenceDeleteSpaceParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceDeleteSpaceParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
spaceId: params.spaceId,
}),
},
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: 'Deleted space ID' },
deleted: { type: 'boolean', description: 'Deletion status' },
},
}

View File

@@ -0,0 +1,107 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceDeleteSpacePropertyParams {
accessToken: string
domain: string
spaceId: string
propertyId: string
cloudId?: string
}
export interface ConfluenceDeleteSpacePropertyResponse {
success: boolean
output: {
ts: string
spaceId: string
propertyId: string
deleted: boolean
}
}
export const confluenceDeleteSpacePropertyTool: ToolConfig<
ConfluenceDeleteSpacePropertyParams,
ConfluenceDeleteSpacePropertyResponse
> = {
id: 'confluence_delete_space_property',
name: 'Confluence Delete Space Property',
description: 'Delete a property from 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: 'Space ID the property belongs to',
},
propertyId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Property ID 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/space-properties',
method: 'POST',
headers: (params: ConfluenceDeleteSpacePropertyParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceDeleteSpacePropertyParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
spaceId: params.spaceId,
action: 'delete',
propertyId: params.propertyId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.spaceId ?? '',
propertyId: data.propertyId ?? '',
deleted: true,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'Space ID' },
propertyId: { type: 'string', description: 'Deleted property ID' },
deleted: { type: 'boolean', description: 'Deletion status' },
},
}

View File

@@ -0,0 +1,147 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetPageDescendantsParams {
accessToken: string
domain: string
pageId: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceGetPageDescendantsResponse {
success: boolean
output: {
ts: string
descendants: Array<{
id: string
title: string
type: string | null
status: string | null
spaceId: string | null
parentId: string | null
childPosition: number | null
depth: number | null
}>
pageId: string
nextCursor: string | null
}
}
export const confluenceGetPageDescendantsTool: ToolConfig<
ConfluenceGetPageDescendantsParams,
ConfluenceGetPageDescendantsResponse
> = {
id: 'confluence_get_page_descendants',
name: 'Confluence Get Page Descendants',
description: 'Get all descendants of a Confluence page recursively.',
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: 'Page ID to get descendants for',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of descendants 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: () => '/api/tools/confluence/page-descendants',
method: 'POST',
headers: (params: ConfluenceGetPageDescendantsParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetPageDescendantsParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
pageId: params.pageId,
limit: params.limit,
cursor: params.cursor,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
descendants: data.descendants || [],
pageId: data.pageId ?? '',
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
descendants: {
type: 'array',
description: 'Array of descendant pages',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Page ID' },
title: { type: 'string', description: 'Page title' },
type: {
type: 'string',
description: 'Content type (page, whiteboard, database, etc.)',
optional: true,
},
status: { type: 'string', description: 'Page status', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
parentId: { type: 'string', description: 'Parent page ID', optional: true },
childPosition: { type: 'number', description: 'Position among siblings', optional: true },
depth: { type: 'number', description: 'Depth in the hierarchy', optional: true },
},
},
},
pageId: { type: 'string', description: 'Parent page ID' },
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -1,4 +1,8 @@
import { DETAILED_VERSION_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import {
BODY_FORMAT_PROPERTIES,
DETAILED_VERSION_OUTPUT_PROPERTIES,
TIMESTAMP_OUTPUT,
} from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetPageVersionParams {
@@ -14,6 +18,8 @@ export interface ConfluenceGetPageVersionResponse {
output: {
ts: string
pageId: string
title: string | null
content: string | null
version: {
number: number
message: string | null
@@ -25,6 +31,12 @@ export interface ConfluenceGetPageVersionResponse {
prevVersion: number | null
nextVersion: number | null
}
body: {
storage?: {
value: string
representation: string
}
} | null
}
}
@@ -100,6 +112,8 @@ export const confluenceGetPageVersionTool: ToolConfig<
output: {
ts: new Date().toISOString(),
pageId: data.pageId ?? '',
title: data.title ?? null,
content: data.content ?? null,
version: data.version ?? {
number: 0,
message: null,
@@ -107,6 +121,7 @@ export const confluenceGetPageVersionTool: ToolConfig<
authorId: null,
createdAt: null,
},
body: data.body ?? null,
},
}
},
@@ -114,10 +129,29 @@ export const confluenceGetPageVersionTool: ToolConfig<
outputs: {
ts: TIMESTAMP_OUTPUT,
pageId: { type: 'string', description: 'ID of the page' },
title: { type: 'string', description: 'Page title at this version', optional: true },
content: {
type: 'string',
description: 'Page content with HTML tags stripped at this version',
optional: true,
},
version: {
type: 'object',
description: 'Detailed version information',
properties: DETAILED_VERSION_OUTPUT_PROPERTIES,
},
body: {
type: 'object',
description: 'Raw page body content in storage format at this version',
properties: {
storage: {
type: 'object',
description: 'Body in storage format (Confluence markup)',
properties: BODY_FORMAT_PROPERTIES,
optional: true,
},
},
optional: true,
},
},
}

View File

@@ -0,0 +1,130 @@
import { 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 | null
spaceId: string | null
pageId: string | null
blogPostId: string | null
status: string
body: string | 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 Confluence inline task by 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)',
},
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) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
taskId: params.taskId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const task = data.task || data
return {
success: true,
output: {
ts: new Date().toISOString(),
id: task.id ?? '',
localId: task.localId ?? null,
spaceId: task.spaceId ?? null,
pageId: task.pageId ?? null,
blogPostId: task.blogPostId ?? null,
status: task.status ?? '',
body: task.body ?? null,
createdBy: task.createdBy ?? null,
assignedTo: task.assignedTo ?? null,
completedBy: task.completedBy ?? null,
createdAt: task.createdAt ?? null,
updatedAt: task.updatedAt ?? null,
dueAt: task.dueAt ?? null,
completedAt: task.completedAt ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
id: { type: 'string', description: 'Task ID' },
localId: { type: 'string', description: 'Local task ID', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
pageId: { type: 'string', description: 'Page ID', optional: true },
blogPostId: { type: 'string', description: 'Blog post ID', optional: true },
status: { type: 'string', description: 'Task status (complete or incomplete)' },
body: { type: 'string', description: 'Task body content in storage format', optional: true },
createdBy: { type: 'string', description: 'Creator account ID', optional: true },
assignedTo: { type: 'string', description: 'Assignee account ID', optional: true },
completedBy: { type: 'string', description: 'Completer account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
dueAt: { type: 'string', description: 'Due date', optional: true },
completedAt: { type: 'string', description: 'Completion timestamp', optional: true },
},
}

View File

@@ -0,0 +1,113 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceGetUserParams {
accessToken: string
domain: string
accountId: string
cloudId?: string
}
export interface ConfluenceGetUserResponse {
success: boolean
output: {
ts: string
accountId: string
displayName: string
email: string | null
accountType: string | null
profilePicture: string | null
publicName: string | null
}
}
export const confluenceGetUserTool: ToolConfig<ConfluenceGetUserParams, ConfluenceGetUserResponse> =
{
id: 'confluence_get_user',
name: 'Confluence Get User',
description: 'Get display name and profile info for a Confluence user by account 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)',
},
accountId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The Atlassian account ID of the user to look up',
},
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/user',
method: 'POST',
headers: (params: ConfluenceGetUserParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceGetUserParams) => ({
domain: params.domain,
accessToken: params.accessToken,
accountId: params.accountId?.trim(),
cloudId: params.cloudId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
accountId: data.accountId ?? '',
displayName: data.displayName ?? '',
email: data.email ?? null,
accountType: data.accountType ?? null,
profilePicture: data.profilePicture?.path ?? null,
publicName: data.publicName ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
accountId: { type: 'string', description: 'Atlassian account ID of the user' },
displayName: { type: 'string', description: 'Display name of the user' },
email: { type: 'string', description: 'Email address of the user', optional: true },
accountType: {
type: 'string',
description: 'Account type (e.g., atlassian, app, customer)',
optional: true,
},
profilePicture: {
type: 'string',
description: 'Path to the user profile picture',
optional: true,
},
publicName: { type: 'string', description: 'Public name of the user', optional: true },
},
}

View File

@@ -3,17 +3,25 @@ import { confluenceCreateBlogPostTool } from '@/tools/confluence/create_blogpost
import { confluenceCreateCommentTool } from '@/tools/confluence/create_comment'
import { confluenceCreatePageTool } from '@/tools/confluence/create_page'
import { confluenceCreatePagePropertyTool } from '@/tools/confluence/create_page_property'
import { confluenceCreateSpaceTool } from '@/tools/confluence/create_space'
import { confluenceCreateSpacePropertyTool } from '@/tools/confluence/create_space_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'
import { confluenceDeletePagePropertyTool } from '@/tools/confluence/delete_page_property'
import { confluenceDeleteSpaceTool } from '@/tools/confluence/delete_space'
import { confluenceDeleteSpacePropertyTool } from '@/tools/confluence/delete_space_property'
import { confluenceGetBlogPostTool } from '@/tools/confluence/get_blogpost'
import { confluenceGetPageAncestorsTool } from '@/tools/confluence/get_page_ancestors'
import { confluenceGetPageChildrenTool } from '@/tools/confluence/get_page_children'
import { confluenceGetPageDescendantsTool } from '@/tools/confluence/get_page_descendants'
import { confluenceGetPageVersionTool } from '@/tools/confluence/get_page_version'
import { confluenceGetPagesByLabelTool } from '@/tools/confluence/get_pages_by_label'
import { confluenceGetSpaceTool } from '@/tools/confluence/get_space'
import { confluenceGetTaskTool } from '@/tools/confluence/get_task'
import { confluenceGetUserTool } from '@/tools/confluence/get_user'
import { confluenceListAttachmentsTool } from '@/tools/confluence/list_attachments'
import { confluenceListBlogPostsTool } from '@/tools/confluence/list_blogposts'
import { confluenceListBlogPostsInSpaceTool } from '@/tools/confluence/list_blogposts_in_space'
@@ -23,7 +31,10 @@ import { confluenceListPagePropertiesTool } from '@/tools/confluence/list_page_p
import { confluenceListPageVersionsTool } from '@/tools/confluence/list_page_versions'
import { confluenceListPagesInSpaceTool } from '@/tools/confluence/list_pages_in_space'
import { confluenceListSpaceLabelsTool } from '@/tools/confluence/list_space_labels'
import { confluenceListSpacePermissionsTool } from '@/tools/confluence/list_space_permissions'
import { confluenceListSpacePropertiesTool } from '@/tools/confluence/list_space_properties'
import { confluenceListSpacesTool } from '@/tools/confluence/list_spaces'
import { confluenceListTasksTool } from '@/tools/confluence/list_tasks'
import { confluenceRetrieveTool } from '@/tools/confluence/retrieve'
import { confluenceSearchTool } from '@/tools/confluence/search'
import { confluenceSearchInSpaceTool } from '@/tools/confluence/search_in_space'
@@ -64,7 +75,10 @@ import {
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 { confluenceUpdateSpaceTool } from '@/tools/confluence/update_space'
import { confluenceUpdateTaskTool } from '@/tools/confluence/update_task'
import { confluenceUploadAttachmentTool } from '@/tools/confluence/upload_attachment'
export {
@@ -76,6 +90,7 @@ export {
confluenceListPagesInSpaceTool,
confluenceGetPageChildrenTool,
confluenceGetPageAncestorsTool,
confluenceGetPageDescendantsTool,
// Page Version Tools
confluenceListPageVersionsTool,
confluenceGetPageVersionTool,
@@ -87,6 +102,8 @@ export {
confluenceListBlogPostsTool,
confluenceGetBlogPostTool,
confluenceCreateBlogPostTool,
confluenceUpdateBlogPostTool,
confluenceDeleteBlogPostTool,
confluenceListBlogPostsInSpaceTool,
// Search Tools
confluenceSearchTool,
@@ -106,9 +123,24 @@ export {
confluenceDeleteLabelTool,
confluenceGetPagesByLabelTool,
confluenceListSpaceLabelsTool,
// User Tools
confluenceGetUserTool,
// Space Tools
confluenceGetSpaceTool,
confluenceCreateSpaceTool,
confluenceUpdateSpaceTool,
confluenceDeleteSpaceTool,
confluenceListSpacesTool,
// Space Property Tools
confluenceListSpacePropertiesTool,
confluenceCreateSpacePropertyTool,
confluenceDeleteSpacePropertyTool,
// Space Permission Tools
confluenceListSpacePermissionsTool,
// Task Tools
confluenceListTasksTool,
confluenceGetTaskTool,
confluenceUpdateTaskTool,
// Item property constants (for use in outputs)
ATTACHMENT_ITEM_PROPERTIES,
COMMENT_ITEM_PROPERTIES,

View File

@@ -0,0 +1,156 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListSpacePermissionsParams {
accessToken: string
domain: string
spaceId: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceListSpacePermissionsResponse {
success: boolean
output: {
ts: string
permissions: Array<{
id: string
principalType: string | null
principalId: string | null
operationKey: string | null
operationTargetType: string | null
anonymousAccess: boolean
unlicensedAccess: boolean
}>
spaceId: string
nextCursor: string | null
}
}
export const confluenceListSpacePermissionsTool: ToolConfig<
ConfluenceListSpacePermissionsParams,
ConfluenceListSpacePermissionsResponse
> = {
id: 'confluence_list_space_permissions',
name: 'Confluence List Space Permissions',
description: 'List permissions for 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: 'Space ID to list permissions for',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of permissions 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: () => '/api/tools/confluence/space-permissions',
method: 'POST',
headers: (params: ConfluenceListSpacePermissionsParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceListSpacePermissionsParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
spaceId: params.spaceId,
limit: params.limit,
cursor: params.cursor,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
permissions: data.permissions || [],
spaceId: data.spaceId ?? '',
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
permissions: {
type: 'array',
description: 'Array of space permissions',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Permission ID' },
principalType: {
type: 'string',
description: 'Principal type (user, group, role)',
optional: true,
},
principalId: { type: 'string', description: 'Principal ID', optional: true },
operationKey: {
type: 'string',
description: 'Operation key (read, create, delete, etc.)',
optional: true,
},
operationTargetType: {
type: 'string',
description: 'Target type (page, blogpost, space, etc.)',
optional: true,
},
anonymousAccess: { type: 'boolean', description: 'Whether anonymous access is allowed' },
unlicensedAccess: {
type: 'boolean',
description: 'Whether unlicensed access is allowed',
},
},
},
},
spaceId: { type: 'string', description: 'Space ID' },
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,133 @@
import { 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
properties: Array<{
id: string
key: string
value: unknown
}>
spaceId: string
nextCursor: string | null
}
}
export const confluenceListSpacePropertiesTool: ToolConfig<
ConfluenceListSpacePropertiesParams,
ConfluenceListSpacePropertiesResponse
> = {
id: 'confluence_list_space_properties',
name: 'Confluence List Space Properties',
description: 'List 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: 'Space ID to list properties for',
},
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: () => '/api/tools/confluence/space-properties',
method: 'POST',
headers: (params: ConfluenceListSpacePropertiesParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceListSpacePropertiesParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
spaceId: params.spaceId,
limit: params.limit,
cursor: params.cursor,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
properties: data.properties || [],
spaceId: data.spaceId ?? '',
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
properties: {
type: 'array',
description: 'Array of space properties',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Property ID' },
key: { type: 'string', description: 'Property key' },
value: { type: 'json', description: 'Property value' },
},
},
},
spaceId: { type: 'string', description: 'Space ID' },
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,181 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceListTasksParams {
accessToken: string
domain: string
pageId?: string
spaceId?: string
assignedTo?: string
status?: string
limit?: number
cursor?: string
cloudId?: string
}
export interface ConfluenceListTasksResponse {
success: boolean
output: {
ts: string
tasks: Array<{
id: string
localId: string | null
spaceId: string | null
pageId: string | null
blogPostId: string | null
status: string
body: string | null
createdBy: string | null
assignedTo: string | null
completedBy: string | null
createdAt: string | null
updatedAt: string | null
dueAt: string | null
completedAt: string | null
}>
nextCursor: string | null
}
}
export const confluenceListTasksTool: ToolConfig<
ConfluenceListTasksParams,
ConfluenceListTasksResponse
> = {
id: 'confluence_list_tasks',
name: 'Confluence List Tasks',
description:
'List inline tasks from Confluence. Optionally filter by page, space, assignee, 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)',
},
pageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter tasks by page ID',
},
spaceId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter tasks by space ID',
},
assignedTo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter tasks by assignee account 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: () => '/api/tools/confluence/tasks',
method: 'POST',
headers: (params: ConfluenceListTasksParams) => ({
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken}`,
}),
body: (params: ConfluenceListTasksParams) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
pageId: params.pageId,
spaceId: params.spaceId,
assignedTo: params.assignedTo,
status: params.status,
limit: params.limit,
cursor: params.cursor,
}),
},
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: {
id: { type: 'string', description: 'Task ID' },
localId: { type: 'string', description: 'Local task ID', optional: true },
spaceId: { type: 'string', description: 'Space ID', optional: true },
pageId: { type: 'string', description: 'Page ID', optional: true },
blogPostId: { type: 'string', description: 'Blog post ID', optional: true },
status: { type: 'string', description: 'Task status (complete or incomplete)' },
body: {
type: 'string',
description: 'Task body content in storage format',
optional: true,
},
createdBy: { type: 'string', description: 'Creator account ID', optional: true },
assignedTo: { type: 'string', description: 'Assignee account ID', optional: true },
completedBy: { type: 'string', description: 'Completer account ID', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
dueAt: { type: 'string', description: 'Due date', optional: true },
completedAt: { type: 'string', description: 'Completion timestamp', optional: true },
},
},
},
nextCursor: {
type: 'string',
description: 'Cursor for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,123 @@
import { TIMESTAMP_OUTPUT } from '@/tools/confluence/types'
import type { ToolConfig } from '@/tools/types'
export interface ConfluenceUpdateBlogPostParams {
accessToken: string
domain: string
blogPostId: string
title?: string
content?: string
cloudId?: string
}
export interface ConfluenceUpdateBlogPostResponse {
success: boolean
output: {
ts: string
blogPostId: string
title: string
status: string | null
spaceId: string | null
version: Record<string, unknown> | null
url: string
}
}
export const confluenceUpdateBlogPostTool: ToolConfig<
ConfluenceUpdateBlogPostParams,
ConfluenceUpdateBlogPostResponse
> = {
id: 'confluence_update_blogpost',
name: 'Confluence Update Blog Post',
description: 'Update an existing Confluence blog post title and/or content.',
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 storage format',
},
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,
cloudId: params.cloudId,
blogPostId: params.blogPostId,
title: params.title,
content: params.content,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
blogPostId: data.id ?? '',
title: data.title ?? '',
status: data.status ?? null,
spaceId: data.spaceId ?? null,
version: data.version ?? null,
url: data._links?.webui ?? '',
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
blogPostId: { type: 'string', description: 'Updated 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 },
version: { type: 'json', description: 'Version information', optional: true },
url: { type: 'string', description: 'URL to view the blog post' },
},
}

View File

@@ -0,0 +1,131 @@
import { SPACE_DESCRIPTION_OUTPUT_PROPERTIES, 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
cloudId?: string
}
export interface ConfluenceUpdateSpaceResponse {
success: boolean
output: {
ts: string
spaceId: string
name: string
key: string
type: string
status: string
url: string
description: { value: string; representation: string } | null
}
}
export const confluenceUpdateSpaceTool: ToolConfig<
ConfluenceUpdateSpaceParams,
ConfluenceUpdateSpaceResponse
> = {
id: 'confluence_update_space',
name: 'Confluence Update Space',
description: 'Update a Confluence space name or description.',
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: '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',
},
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',
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,
cloudId: params.cloudId,
spaceId: params.spaceId,
name: params.name,
description: params.description,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ts: new Date().toISOString(),
spaceId: data.id ?? '',
name: data.name ?? '',
key: data.key ?? '',
type: data.type ?? '',
status: data.status ?? '',
url: data._links?.webui ?? '',
description: data.description ?? null,
},
}
},
outputs: {
ts: TIMESTAMP_OUTPUT,
spaceId: { type: 'string', description: 'Updated space ID' },
name: { type: 'string', description: 'Space name' },
key: { type: 'string', description: 'Space key' },
type: { type: 'string', description: 'Space type' },
status: { type: 'string', description: 'Space status' },
url: { type: 'string', description: 'URL to view the space' },
description: {
type: 'object',
description: 'Space description',
properties: SPACE_DESCRIPTION_OUTPUT_PROPERTIES,
optional: true,
},
},
}

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