Compare commits

...

16 Commits

Author SHA1 Message Date
Waleed
d581009099 v0.6.19: vllm fixes, loading improevments, reactquery standardization, new gpt 5.4 models, fireworks provider support, launchdarkly, tailscale, extend integrations 2026-03-31 20:17:00 -07:00
Waleed
dcebe3ae97 improvement(triggers): add tags to all trigger.dev task invocations (#3878)
* improvement(triggers): add tags to all trigger.dev task invocations

* fix(triggers): prefix unused type param in buildTags

* fix(triggers): remove unused type param from buildTags
2026-03-31 19:52:33 -07:00
Waleed
e39c534ee3 feat(providers): add Fireworks AI provider integration (#3873)
* feat(providers): add Fireworks AI provider integration

* fix(providers): remove unused logger and dead modelInfo from fireworks

* lint

* feat(providers): add Fireworks BYOK support and official icon

* fix(providers): add workspace membership check and remove shared fetch cache for fireworks models
2026-03-31 19:22:04 -07:00
Vikhyath Mondreti
b95a0491a0 fix(kb): chunking config persistence (#3877)
* fix(kb): persist chunking config correctly

* fix kb config as sot

* remove dead code

* fix doc req bodies

* add defaults for async for legacy docs
2026-03-31 19:16:23 -07:00
Waleed
a79c8a75ce fix(chat): align floating chat send button colors with home/mothership chat (#3876) 2026-03-31 18:11:02 -07:00
Vikhyath Mondreti
282ec8c58c fix(reorder): drag and drop hook (#3874)
* fix(reorder): drag and drop hook

* fix custom tool dropdown color

* fix mcp server url change propagation
2026-03-31 17:33:08 -07:00
Waleed
e45fbe0184 improvement(attio): validate integration, fix event bug, add missing tool and triggers (#3872)
* improvement(attio): validate integration, fix event bug, add missing tool and triggers

* fix(attio): wire new trigger extractors into dispatcher, trim targetUrl

Add extractAttioListData and extractAttioWorkspaceMemberData dispatch
branches in utils.server.ts so the four new triggers return correct
outputs instead of falling through to generic extraction.

Also add missing .trim() on targetUrl in update_webhook.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:44:49 -07:00
Waleed
512558dcb3 feat(launchdarkly): add LaunchDarkly integration for feature flag management (#3870)
* feat(launchdarkly): add LaunchDarkly integration for feature flag management

* fix(launchdarkly): guard empty instructions array, trim apiKey in auth header

* lint
2026-03-31 16:40:06 -07:00
Waleed
35411e465e feat(models): add gpt-5.4-mini and gpt-5.4-nano (#3871) 2026-03-31 16:32:24 -07:00
Waleed
72e28baa07 feat(extend): add Extend AI document processing integration (#3869)
* feat(extend): add Extend AI document processing integration

* fix(extend): cast json response to fix type error

* fix(extend): correct API request body structure per Extend docs

* fix(extend): address PR review comments

* fix(extend): sync integrations.json bgColor to #000000

* lint
2026-03-31 16:26:34 -07:00
Waleed
d99dd86bf2 feat(tailscale): add Tailscale integration with 20 API operations (#3868)
* feat(tailscale): add Tailscale integration with 20 API operations

* fix(tailscale): fix transformResponse signatures and block output types

* fix(tailscale): safe response.json() pattern, trim apiKey, guard expirySeconds
2026-03-31 16:26:17 -07:00
Waleed
7898e5d75f improvement(workflows): replace Zustand workflow sync with React Query as single source of truth (#3860)
* improvement(workflows): replace Zustand workflow sync with React Query as single source of truth

* fix(workflows): address PR review feedback — sandbox execution, hydration deadlock, test mock, copy casing

* lint

* improvement(workflows): adopt skipToken over enabled+as-string for type-safe conditional queries

* improvement(workflows): remove dead complexity, fix mutation edge cases

- Throw on state PUT failure in useCreateWorkflow instead of swallowing
- Use Map for O(1) lookups in duplicate/export loops (3 hooks)
- Broaden invalidation scope in update/delete mutations to lists()
- Switch workflow-block to useWorkflowMap for direct ID lookup
- Consolidate use-workflow-operations to single useWorkflowMap hook
- Remove workspace transition guard (sync body, unreachable timeout)
- Make switchToWorkspace synchronous (remove async/try-catch/finally)

* fix(workflows): resolve cold-start deadlock on direct URL navigation

loadWorkflowState used hydration.workspaceId (null on cold start) to
look up the RQ cache, causing "Workflow not found" even when the
workflow exists in the DB. Now falls back to getWorkspaceIdFromUrl()
and skips the cache guard when the cache is empty (letting the API
fetch proceed).

Also removes the redundant isRegistryReady guard in workflow.tsx that
blocked setActiveWorkflow when hydration.workspaceId was null.

* fix(ui): prevent flash of empty state while workflows query is pending

Dashboard and EmbeddedWorkflow checked workflow list length before
the RQ query resolved, briefly showing "No workflows" or "Workflow
not found" on initial load. Now gates on isPending first.

* fix(workflows): address PR review — await description update, revert state PUT throw

- api-info-modal: use mutateAsync for description update so errors
  are caught by the surrounding try/catch instead of silently swallowed
- useCreateWorkflow: revert state PUT to log-only — the workflow is
  already created in the DB, throwing rolls back the optimistic entry
  and makes it appear the creation failed when it actually succeeded

* move folders over to react query native, restructure passage of data

* pass signal correctly

* fix types

* fix workspace id

* address comment

* soft deletion accuring

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-03-31 15:49:16 -07:00
Waleed
df62502903 feat(infra): add dev environment support (#3867)
* feat(infra): add dev environment support

* fix(ci): push :dev ECR tag when building from dev branch

* fix(feature-flags): simplify isHosted subdomain check

* fix(ci,feature-flags): guard URL parse, fix dev AWS creds in images.yml
2026-03-31 15:36:57 -07:00
Waleed
1a2aa6949e feat(secrets-manager): add AWS Secrets Manager integration (#3866)
* feat(secrets-manager): add AWS Secrets Manager integration

* fix(secrets-manager): address PR review feedback

- Conditional delete message based on forceDelete flag
- Add binary secret detection in getSecretValue

* fix(secrets-manager): handle boolean forceDelete and validate numeric inputs

- Accept both string 'true' and boolean true for forceDelete
- Guard parseInt results with isNaN check for maxResults and recoveryWindowInDays
2026-03-31 15:26:03 -07:00
Waleed
4544fd4519 improvement(ui): fix nav loading flash, skeleton mismatches, and React anti-patterns across resource pages (#3864)
* improvement(ui): fix nav loading flash, skeleton mismatches, and React anti-patterns across resource pages

- Convert knowledge, files, tables, scheduled-tasks, and home page.tsx files from async server components to simple client re-exports, eliminating the loading.tsx flash on every navigation
- Add client-side permission redirects (usePermissionConfig) to knowledge, files, and tables components to replace server-side checks
- Fix knowledge loading.tsx skeleton column count (6→7) and tables loading.tsx (remove phantom checkbox column)
- Fix connector document live updates: use isConnectorSyncingOrPending instead of status === 'syncing' so polling activates immediately after connector creation
- Remove dead chunk-switch useEffect in ChunkEditor (redundant with key prop remount)
- Replace useState+useEffect debounce with useDebounce hook in document.tsx
- Replace useRef+useEffect URL init with lazy useState initializers in document.tsx and logs.tsx
- Make handleToggleEnabled optimistic in document.tsx (cache first, onError rollback)
- Replace mutate+new Promise wrapper with mutateAsync+try/catch in base.tsx
- Fix schedule-modal.tsx: replace 15-setter useEffect with useState lazy initializers + key prop remount; wrap parseCronToScheduleType in useMemo
- Fix logs search: eliminate mount-only useEffect with eslint-disable by passing initialQuery to useSearchState; parse query once via shared initialParsed state
- Add useWorkspaceFileRecord hook to workspace-files.ts; refactor FileViewer to self-fetch
- Fix value: any → value: string in useTagSelection and collaborativeSetTagSelection
- Fix knowledge-tag-filters.tsx: pass '' instead of null when filters are cleared (type safety)

* fix(kb): use active scope in useWorkspaceFileRecord to share cache with useWorkspaceFiles

* fix(logs,kb,tasks): lazy-init useRef for URL param, add cold-path docs to useWorkspaceFileRecord, document key remount requirement in ScheduleModal

* fix(files): redirect to files list when file record not found in viewer

* revert(files): remove useEffect redirect from file-viewer, keep simple null return

* fix(scheduled-tasks): correct useMemo dep from schedule?.cronExpression to schedule
2026-03-31 11:56:58 -07:00
Geonwoo Kim
019630bdc8 fix(vllm): pass env.VLLM_API_KEY to chat requests (#3865) 2026-03-31 10:33:44 -07:00
284 changed files with 11923 additions and 2534 deletions

View File

@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, staging]
branches: [main, staging, dev]
pull_request:
branches: [main, staging]
branches: [main, staging, dev]
concurrency:
group: ci-${{ github.ref }}
@@ -23,7 +23,7 @@ jobs:
detect-version:
name: Detect Version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
outputs:
version: ${{ steps.extract.outputs.version }}
is_release: ${{ steps.extract.outputs.is_release }}
@@ -49,7 +49,7 @@ jobs:
build-amd64:
name: Build AMD64
needs: [test-build, detect-version]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
@@ -75,8 +75,8 @@ jobs:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
@@ -109,6 +109,8 @@ jobs:
# ECR tags (always build for ECR)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
ECR_TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
ECR_TAG="dev"
else
ECR_TAG="staging"
fi

View File

@@ -36,8 +36,8 @@ jobs:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
@@ -70,6 +70,8 @@ jobs:
# ECR tags (always build for ECR)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
ECR_TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
ECR_TAG="dev"
else
ECR_TAG="staging"
fi

View File

@@ -683,6 +683,45 @@ export function SerperIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TailscaleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512' fill='none' {...props}>
<path
fill='currentColor'
opacity={0.2}
d='M65.6 127.7c35.3 0 63.9-28.6 63.9-63.9S100.9 0 65.6 0 1.8 28.6 1.8 63.9s28.6 63.8 63.8 63.8'
/>
<path
fill='currentColor'
d='M65.6 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9S1.8 219 1.8 254.2s28.6 63.9 63.8 63.9'
/>
<path
fill='currentColor'
opacity={0.2}
d='M65.6 512c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.8 28.7-63.8 63.9S30.4 512 65.6 512'
/>
<path
fill='currentColor'
d='M257.2 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m0 193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9'
/>
<path
fill='currentColor'
opacity={0.2}
d='M257.2 127.7c35.3 0 63.9-28.6 63.9-63.9S292.5 0 257.2 0s-63.9 28.6-63.9 63.9 28.6 63.8 63.9 63.8m189.2 0c35.3 0 63.9-28.6 63.9-63.9S481.6 0 446.4 0c-35.3 0-63.9 28.6-63.9 63.9s28.6 63.8 63.9 63.8'
/>
<path
fill='currentColor'
d='M446.4 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9'
/>
<path
fill='currentColor'
opacity={0.2}
d='M446.4 512c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9'
/>
</svg>
)
}
export function TavilyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 600 600' xmlns='http://www.w3.org/2000/svg' {...props}>
@@ -2041,6 +2080,19 @@ export function Mem0Icon(props: SVGProps<SVGSVGElement>) {
)
}
export function ExtendIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 33 18' fill='none'>
<path
clipRule='evenodd'
d='M16.2893 0C16.6984 1.91708e-05 17.1074 0.0970011 17.5103 0.293745C22.3018 2.63326 27.0841 4.98521 31.8693 7.33722C32.3003 7.54649 32.5721 7.9868 32.5721 8.46461V9.51422C32.5721 9.99522 32.3004 10.4357 31.8693 10.645C31.8693 10.645 19.5816 16.6732 17.5542 17.6634C17.1357 17.8696 16.692 17.9727 16.2859 17.9727C15.8799 17.9727 15.4707 17.8758 15.0615 17.6759C12.8124 16.5795 1.9646 11.2604 0.705842 10.6419C0.274826 10.4295 2.31482e-05 9.99216 0 9.51117V8.46461C4.59913e-05 7.98366 0.271816 7.54656 0.702792 7.33417C5.8977 4.7819 15.0599 0.301869 15.1021 0.281239C15.4957 0.0938275 15.8801 0 16.2893 0ZM16.2859 2.96124C16.1516 2.96126 16.0173 2.98909 15.8924 3.05153L4.28874 8.77696C4.11382 8.86442 4.11382 9.10831 4.28874 9.19577L15.8924 14.9209C16.0173 14.9802 16.1516 15.0115 16.2859 15.0115C16.4202 15.0115 16.5548 14.9802 16.6797 14.9209L28.2864 9.19577C28.4582 9.10831 28.4582 8.86442 28.2864 8.77696L16.6797 3.05153C16.5548 2.98906 16.4202 2.96124 16.2859 2.96124Z'
fill='currentColor'
fillRule='evenodd'
/>
</svg>
)
}
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
@@ -2152,6 +2204,17 @@ export function LangsmithIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function LaunchDarklyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 216 214.94' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='currentColor'
d='M109.8,214.94a4.87,4.87,0,0,1-4.26-2.66,4.5,4.5,0,0,1,.44-4.82l50.49-69.53L68,174.11a4.61,4.61,0,0,1-1.9.41,4.77,4.77,0,0,1-4.52-3.4,4.57,4.57,0,0,1,2-5.21L141.33,120,4.41,112.13a4.69,4.69,0,0,1,0-9.36l137-7.87L63.61,49a4.56,4.56,0,0,1-1.94-5.2,4.74,4.74,0,0,1,4.51-3.4,4.6,4.6,0,0,1,1.9.4L156.5,77,106,7.48a4.56,4.56,0,0,1-.44-4.83A4.84,4.84,0,0,1,109.84,0a4.59,4.59,0,0,1,3.28,1.41L213.77,102.05a7.65,7.65,0,0,1,0,10.8L113.08,213.53A4.59,4.59,0,0,1,109.8,214.94Z'
/>
</svg>
)
}
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='24 24.92 132 132' fill='none'>
@@ -4502,6 +4565,24 @@ export function DynamoDBIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SecretsManagerIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg'>
<defs>
<linearGradient x1='0%' y1='100%' x2='100%' y2='0%' id='secretsManagerGradient'>
<stop stopColor='#BD0816' offset='0%' />
<stop stopColor='#FF5252' offset='100%' />
</linearGradient>
</defs>
<rect fill='url(#secretsManagerGradient)' width='80' height='80' />
<path
d='M38.76,43.36 C38.76,44.044 39.317,44.6 40,44.6 C40.684,44.6 41.24,44.044 41.24,43.36 C41.24,42.676 40.684,42.12 40,42.12 C39.317,42.12 38.76,42.676 38.76,43.36 L38.76,43.36 Z M36.76,43.36 C36.76,41.573 38.213,40.12 40,40.12 C41.787,40.12 43.24,41.573 43.24,43.36 C43.24,44.796 42.296,46.002 41,46.426 L41,49 L39,49 L39,46.426 C37.704,46.002 36.76,44.796 36.76,43.36 L36.76,43.36 Z M49,38 L31,38 L31,51 L49,51 L49,48 L46,48 L46,46 L49,46 L49,43 L46,43 L46,41 L49,41 L49,38 Z M34,36 L45.999,36 L46,31 C46.001,28.384 43.143,26.002 40.004,26 L40.001,26 C38.472,26 36.928,26.574 35.763,27.575 C34.643,28.537 34,29.786 34,31.001 L34,36 Z M48,31.001 L47.999,36 L50,36 C50.553,36 51,36.448 51,37 L51,52 C51,52.552 50.553,53 50,53 L30,53 C29.447,53 29,52.552 29,52 L29,37 C29,36.448 29.447,36 30,36 L32,36 L32,31 C32.001,29.202 32.897,27.401 34.459,26.058 C35.982,24.75 38.001,24 40.001,24 L40.004,24 C44.265,24.002 48.001,27.273 48,31.001 L48,31.001 Z M19.207,55.049 L20.828,53.877 C18.093,50.097 16.581,45.662 16.396,41 L19,41 L19,39 L16.399,39 C16.598,34.366 18.108,29.957 20.828,26.198 L19.207,25.025 C16.239,29.128 14.599,33.942 14.399,39 L12,39 L12,41 L14.396,41 C14.582,46.086 16.224,50.926 19.207,55.049 L19.207,55.049 Z M53.838,59.208 C50.069,61.936 45.648,63.446 41,63.639 L41,61 L39,61 L39,63.639 C34.352,63.447 29.93,61.937 26.159,59.208 L24.988,60.828 C29.1,63.805 33.928,65.445 39,65.639 L39,68 L41,68 L41,65.639 C46.072,65.445 50.898,63.805 55.01,60.828 L53.838,59.208 Z M26.159,20.866 C29.93,18.138 34.352,16.628 39,16.436 L39,19 L41,19 L41,16.436 C45.648,16.628 50.069,18.138 53.838,20.866 L55.01,19.246 C50.898,16.27 46.072,14.63 41,14.436 L41,12 L39,12 L39,14.436 C33.928,14.629 29.1,16.269 24.988,19.246 L26.159,20.866 Z M65.599,39 C65.399,33.942 63.759,29.128 60.79,25.025 L59.169,26.198 C61.89,29.957 63.4,34.366 63.599,39 L61,39 L61,41 L63.602,41 C63.416,45.662 61.905,50.097 59.169,53.877 L60.79,55.049 C63.774,50.926 65.415,46.086 65.602,41 L68,41 L68,39 L65.599,39 Z M56.386,25.064 L64.226,17.224 L62.812,15.81 L54.972,23.65 L56.386,25.064 Z M23.612,55.01 L15.772,62.85 L17.186,64.264 L25.026,56.424 L23.612,55.01 Z M28.666,27.253 L13.825,12.413 L12.411,13.827 L27.252,28.667 L28.666,27.253 Z M54.193,52.78 L67.586,66.173 L66.172,67.587 L52.779,54.194 L54.193,52.78 Z'
fill='#FFFFFF'
/>
</svg>
)
}
export function SQSIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -45,6 +45,7 @@ import {
EnrichSoIcon,
EvernoteIcon,
ExaAIIcon,
ExtendIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
@@ -91,6 +92,7 @@ import {
KalshiIcon,
KetchIcon,
LangsmithIcon,
LaunchDarklyIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
@@ -140,6 +142,7 @@ import {
S3Icon,
SalesforceIcon,
SearchIcon,
SecretsManagerIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -155,6 +158,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -221,6 +225,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
extend_v2: ExtendIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
@@ -269,6 +274,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -317,6 +323,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
secrets_manager: SecretsManagerIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
@@ -333,6 +340,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
stripe: StripeIcon,
stt_v2: STTIcon,
supabase: SupabaseIcon,
tailscale: TailscaleIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract_v2: TextractIcon,

View File

@@ -359,6 +359,35 @@ List tasks in Attio, optionally filtered by record, assignee, or completion stat
| ↳ `createdAt` | string | When the task was created |
| `count` | number | Number of tasks returned |
### `attio_get_task`
Get a single task by ID from Attio
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskId` | string | Yes | The ID of the task to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | The task ID |
| `content` | string | The task content |
| `deadlineAt` | string | The task deadline |
| `isCompleted` | boolean | Whether the task is completed |
| `linkedRecords` | array | Records linked to this task |
| ↳ `targetObjectId` | string | The linked object ID |
| ↳ `targetRecordId` | string | The linked record ID |
| `assignees` | array | Task assignees |
| ↳ `type` | string | The assignee actor type \(e.g. workspace-member\) |
| ↳ `id` | string | The assignee actor ID |
| `createdByActor` | object | The actor who created this task |
| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) |
| ↳ `id` | string | The actor ID |
| `createdAt` | string | When the task was created |
### `attio_create_task`
Create a task in Attio
@@ -1012,8 +1041,8 @@ Update a webhook in Attio (target URL and/or subscriptions)
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `webhookId` | string | Yes | The webhook ID to update |
| `targetUrl` | string | Yes | HTTPS target URL for webhook delivery |
| `subscriptions` | string | Yes | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] |
| `targetUrl` | string | No | HTTPS target URL for webhook delivery |
| `subscriptions` | string | No | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] |
#### Output

View File

@@ -0,0 +1,39 @@
---
title: Extend
description: Parse and extract content from documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="extend_v2"
color="#000000"
/>
## Usage Instructions
Integrate Extend AI into the workflow. Parse and extract structured content from documents or file references.
## Tools
### `extend_parser`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filePath` | string | No | URL to a document to be processed |
| `file` | file | No | Document file to be processed |
| `fileUpload` | object | No | File upload data from file-upload component |
| `outputFormat` | string | No | Target output format \(markdown or spatial\). Defaults to markdown. |
| `chunking` | string | No | Chunking strategy \(page, document, or section\). Defaults to page. |
| `engine` | string | No | Parsing engine \(parse_performance or parse_light\). Defaults to parse_performance. |
| `apiKey` | string | Yes | Extend API key |
#### Output
This tool does not produce any outputs.

View File

@@ -0,0 +1,388 @@
---
title: LaunchDarkly
description: Manage feature flags with LaunchDarkly.
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="launchdarkly"
color="#191919"
/>
{/* MANUAL-CONTENT-START:intro */}
[LaunchDarkly](https://launchdarkly.com/) is a feature management platform that enables teams to safely deploy, control, and measure their software features at scale.
With the LaunchDarkly integration in Sim, you can:
- **Feature flag management** — List, create, update, toggle, and delete feature flags programmatically. Toggle flags on or off in specific environments using LaunchDarkly's semantic patch API.
- **Flag status monitoring** — Check whether a flag is active, inactive, new, or launched in a given environment. Track the last time a flag was evaluated.
- **Project and environment management** — List all projects and their environments to understand your LaunchDarkly organization structure.
- **User segments** — List user segments within a project and environment to understand how your audience is organized for targeting.
- **Team visibility** — List account members and their roles for auditing and access management workflows.
- **Audit log** — Retrieve recent audit log entries to track who changed what, when. Filter entries by resource type for targeted monitoring.
In Sim, the LaunchDarkly integration enables your agents to automate feature flag operations as part of their workflows. This allows for automation scenarios such as toggling flags on/off based on deployment pipeline events, monitoring flag status and alerting on stale or unused flags, auditing flag changes by querying the audit log after deployments, syncing flag metadata with your project management tools, and listing all feature flags across projects for governance.
## Authentication
This integration uses a LaunchDarkly API key. You can create personal access tokens or service tokens in the LaunchDarkly dashboard under **Account Settings > Authorization**. The API key is passed directly in the `Authorization` header (no `Bearer` prefix).
## Need Help?
If you encounter issues with the LaunchDarkly integration, contact us at [help@sim.ai](mailto:help@sim.ai)
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate LaunchDarkly into your workflow. List, create, update, toggle, and delete feature flags. Manage projects, environments, segments, members, and audit logs. Requires API Key.
## Tools
### `launchdarkly_create_flag`
Create a new feature flag in a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key to create the flag in |
| `name` | string | Yes | Human-readable name for the feature flag |
| `key` | string | Yes | Unique key for the feature flag \(used in code\) |
| `description` | string | No | Description of the feature flag |
| `tags` | string | No | Comma-separated list of tags |
| `temporary` | boolean | No | Whether the flag is temporary \(default true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |
### `launchdarkly_delete_flag`
Delete a feature flag from a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the flag was successfully deleted |
### `launchdarkly_get_audit_log`
List audit log entries from your LaunchDarkly account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `limit` | number | No | Maximum number of entries to return \(default 10, max 20\) |
| `spec` | string | No | Filter expression \(e.g., "resourceType:flag"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `entries` | array | List of audit log entries |
| ↳ `id` | string | The audit log entry ID |
| ↳ `date` | number | Unix timestamp in milliseconds |
| ↳ `kind` | string | The type of action performed |
| ↳ `name` | string | The name of the resource acted on |
| ↳ `description` | string | Full description of the action |
| ↳ `shortDescription` | string | Short description of the action |
| ↳ `memberEmail` | string | Email of the member who performed the action |
| ↳ `targetName` | string | Name of the target resource |
| ↳ `targetKind` | string | Kind of the target resource |
| `totalCount` | number | Total number of audit log entries |
### `launchdarkly_get_flag`
Get a single feature flag by key from a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key |
| `environmentKey` | string | No | Filter flag configuration to a specific environment |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |
| `on` | boolean | Whether the flag is on in the requested environment \(null if no single environment was specified\) |
### `launchdarkly_get_flag_status`
Get the status of a feature flag across environments (active, inactive, launched, etc.).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key |
| `environmentKey` | string | Yes | The environment key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | The flag status \(new, active, inactive, launched\) |
| `lastRequested` | string | Timestamp of the last evaluation |
| `defaultVal` | string | The default variation value |
### `launchdarkly_list_environments`
List environments in a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key to list environments for |
| `limit` | number | No | Maximum number of environments to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `environments` | array | List of environments |
| ↳ `id` | string | The environment ID |
| ↳ `key` | string | The unique environment key |
| ↳ `name` | string | The environment name |
| ↳ `color` | string | The color assigned to this environment |
| ↳ `apiKey` | string | The server-side SDK key for this environment |
| ↳ `mobileKey` | string | The mobile SDK key for this environment |
| ↳ `tags` | array | Tags applied to the environment |
| `totalCount` | number | Total number of environments |
### `launchdarkly_list_flags`
List feature flags in a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key to list flags for |
| `environmentKey` | string | No | Filter flag configurations to a specific environment |
| `tag` | string | No | Filter flags by tag name |
| `limit` | number | No | Maximum number of flags to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `flags` | array | List of feature flags |
| ↳ `key` | string | The unique key of the feature flag |
| ↳ `name` | string | The human-readable name of the feature flag |
| ↳ `kind` | string | The type of flag \(boolean or multivariate\) |
| ↳ `description` | string | Description of the feature flag |
| ↳ `temporary` | boolean | Whether the flag is temporary |
| ↳ `archived` | boolean | Whether the flag is archived |
| ↳ `deprecated` | boolean | Whether the flag is deprecated |
| ↳ `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| ↳ `tags` | array | Tags applied to the flag |
| ↳ `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| ↳ `maintainerId` | string | The ID of the member who maintains this flag |
| `totalCount` | number | Total number of flags |
### `launchdarkly_list_members`
List account members in your LaunchDarkly organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `limit` | number | No | Maximum number of members to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | List of account members |
| ↳ `id` | string | The member ID |
| ↳ `email` | string | The member email address |
| ↳ `firstName` | string | The member first name |
| ↳ `lastName` | string | The member last name |
| ↳ `role` | string | The member role \(reader, writer, admin, owner\) |
| ↳ `lastSeen` | number | Unix timestamp of last activity |
| ↳ `creationDate` | number | Unix timestamp when the member was created |
| ↳ `verified` | boolean | Whether the member email is verified |
| `totalCount` | number | Total number of members |
### `launchdarkly_list_projects`
List all projects in your LaunchDarkly account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `limit` | number | No | Maximum number of projects to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `projects` | array | List of projects |
| ↳ `id` | string | The project ID |
| ↳ `key` | string | The unique project key |
| ↳ `name` | string | The project name |
| ↳ `tags` | array | Tags applied to the project |
| `totalCount` | number | Total number of projects |
### `launchdarkly_list_segments`
List user segments in a LaunchDarkly project and environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `environmentKey` | string | Yes | The environment key |
| `limit` | number | No | Maximum number of segments to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `segments` | array | List of user segments |
| ↳ `key` | string | The unique segment key |
| ↳ `name` | string | The segment name |
| ↳ `description` | string | The segment description |
| ↳ `tags` | array | Tags applied to the segment |
| ↳ `creationDate` | number | Unix timestamp in milliseconds when the segment was created |
| ↳ `unbounded` | boolean | Whether this is an unbounded \(big\) segment |
| ↳ `included` | array | User keys explicitly included in the segment |
| ↳ `excluded` | array | User keys explicitly excluded from the segment |
| `totalCount` | number | Total number of segments |
### `launchdarkly_toggle_flag`
Toggle a feature flag on or off in a specific LaunchDarkly environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key to toggle |
| `environmentKey` | string | Yes | The environment key to toggle the flag in |
| `enabled` | boolean | Yes | Whether to turn the flag on \(true\) or off \(false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |
| `on` | boolean | Whether the flag is now on in the target environment |
### `launchdarkly_update_flag`
Update a feature flag metadata (name, description, tags, temporary, archived) using semantic patch.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key to update |
| `updateName` | string | No | New name for the flag |
| `updateDescription` | string | No | New description for the flag |
| `addTags` | string | No | Comma-separated tags to add |
| `removeTags` | string | No | Comma-separated tags to remove |
| `archive` | boolean | No | Set to true to archive, false to restore |
| `comment` | string | No | Optional comment explaining the update |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |

View File

@@ -39,6 +39,7 @@
"enrich",
"evernote",
"exa",
"extend",
"fathom",
"file",
"firecrawl",
@@ -87,6 +88,7 @@
"ketch",
"knowledge",
"langsmith",
"launchdarkly",
"lemlist",
"linear",
"linkedin",
@@ -135,6 +137,7 @@
"s3",
"salesforce",
"search",
"secrets_manager",
"sendgrid",
"sentry",
"serper",
@@ -152,6 +155,7 @@
"stt",
"supabase",
"table",
"tailscale",
"tavily",
"telegram",
"textract",

View File

@@ -0,0 +1,157 @@
---
title: AWS Secrets Manager
description: Connect to AWS Secrets Manager
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="secrets_manager"
color="linear-gradient(45deg, #BD0816 0%, #FF5252 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) is a secrets management service that helps you protect access to your applications, services, and IT resources. It enables you to rotate, manage, and retrieve database credentials, API keys, and other secrets throughout their lifecycle.
With AWS Secrets Manager, you can:
- **Securely store secrets**: Encrypt secrets at rest using AWS KMS encryption keys
- **Retrieve secrets programmatically**: Access secrets from your applications and workflows without hardcoding credentials
- **Rotate secrets automatically**: Configure automatic rotation for supported services like RDS, Redshift, and DocumentDB
- **Audit access**: Track secret access and changes through AWS CloudTrail integration
- **Control access with IAM**: Use fine-grained IAM policies to manage who can access which secrets
- **Replicate across regions**: Automatically replicate secrets to multiple AWS regions for disaster recovery
In Sim, the AWS Secrets Manager integration allows your workflows to securely retrieve credentials and configuration values at runtime, create and manage secrets as part of automation pipelines, and maintain a centralized secrets store that your agents can access. This is particularly useful for workflows that need to authenticate with external services, rotate credentials, or manage sensitive configuration across environments — all without exposing secrets in your workflow definitions.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.
## Tools
### `secrets_manager_get_secret`
Retrieve a secret value from AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `secretId` | string | Yes | The name or ARN of the secret to retrieve |
| `versionId` | string | No | The unique identifier of the version to retrieve |
| `versionStage` | string | No | The staging label of the version to retrieve \(e.g., AWSCURRENT, AWSPREVIOUS\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Name of the secret |
| `secretValue` | string | The decrypted secret value |
| `arn` | string | ARN of the secret |
| `versionId` | string | Version ID of the secret |
| `versionStages` | array | Staging labels attached to this version |
| `createdDate` | string | Date the secret was created |
### `secrets_manager_list_secrets`
List secrets stored in AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `maxResults` | number | No | Maximum number of secrets to return \(1-100, default 100\) |
| `nextToken` | string | No | Pagination token from a previous request |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secrets` | json | List of secrets with name, ARN, description, and dates |
| `nextToken` | string | Pagination token for the next page of results |
| `count` | number | Number of secrets returned |
### `secrets_manager_create_secret`
Create a new secret in AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `name` | string | Yes | Name of the secret to create |
| `secretValue` | string | Yes | The secret value \(plain text or JSON string\) |
| `description` | string | No | Description of the secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `name` | string | Name of the created secret |
| `arn` | string | ARN of the created secret |
| `versionId` | string | Version ID of the created secret |
### `secrets_manager_update_secret`
Update the value of an existing secret in AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `secretId` | string | Yes | The name or ARN of the secret to update |
| `secretValue` | string | Yes | The new secret value \(plain text or JSON string\) |
| `description` | string | No | Updated description of the secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `name` | string | Name of the updated secret |
| `arn` | string | ARN of the updated secret |
| `versionId` | string | Version ID of the updated secret |
### `secrets_manager_delete_secret`
Delete a secret from AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `secretId` | string | Yes | The name or ARN of the secret to delete |
| `recoveryWindowInDays` | number | No | Number of days before permanent deletion \(7-30, default 30\) |
| `forceDelete` | boolean | No | If true, immediately delete without recovery window |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `name` | string | Name of the deleted secret |
| `arn` | string | ARN of the deleted secret |
| `deletionDate` | string | Scheduled deletion date |

View File

@@ -0,0 +1,490 @@
---
title: Tailscale
description: Manage devices and network settings in your Tailscale tailnet
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="tailscale"
color="#2E2D2D"
/>
## Overview
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on WireGuard that makes it easy to connect devices, services, and users across any network. The Tailscale block lets you automate network management tasks like device provisioning, access control, route management, and DNS configuration directly from your Sim workflows.
## Authentication
The Tailscale block uses API key authentication. To get an API key:
1. Go to the [Tailscale admin console](https://login.tailscale.com/admin/settings/keys)
2. Navigate to **Settings > Keys**
3. Click **Generate API key**
4. Set an expiry (1-90 days) and copy the key (starts with `tskey-api-`)
You must have an **Owner**, **Admin**, **IT admin**, or **Network admin** role to generate API keys.
## Tailnet Identifier
Every operation requires a **tailnet** parameter. This is typically your organization's domain name (e.g., `example.com`). You can also use `"-"` to refer to your default tailnet.
## Common Use Cases
- **Device inventory**: List and monitor all devices connected to your network
- **Automated provisioning**: Create and manage auth keys to pre-authorize new devices
- **Access control**: Authorize or deauthorize devices, manage device tags for ACL policies
- **Route management**: View and enable subnet routes for devices acting as subnet routers
- **DNS management**: Configure nameservers, MagicDNS, and search paths
- **Key lifecycle**: Create, list, inspect, and revoke auth keys
- **User auditing**: List all users in the tailnet and their roles
- **Policy review**: Retrieve the current ACL policy for inspection or backup
## Tools
### `tailscale_list_devices`
List all devices in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `devices` | array | List of devices in the tailnet |
| ↳ `id` | string | Device ID |
| ↳ `name` | string | Device name |
| ↳ `hostname` | string | Device hostname |
| ↳ `user` | string | Associated user |
| ↳ `os` | string | Operating system |
| ↳ `clientVersion` | string | Tailscale client version |
| ↳ `addresses` | array | Tailscale IP addresses |
| ↳ `tags` | array | Device tags |
| ↳ `authorized` | boolean | Whether the device is authorized |
| ↳ `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
| ↳ `lastSeen` | string | Last seen timestamp |
| ↳ `created` | string | Creation timestamp |
| `count` | number | Total number of devices |
### `tailscale_get_device`
Get details of a specific device by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Device ID |
| `name` | string | Device name |
| `hostname` | string | Device hostname |
| `user` | string | Associated user |
| `os` | string | Operating system |
| `clientVersion` | string | Tailscale client version |
| `addresses` | array | Tailscale IP addresses |
| `tags` | array | Device tags |
| `authorized` | boolean | Whether the device is authorized |
| `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
| `lastSeen` | string | Last seen timestamp |
| `created` | string | Creation timestamp |
| `enabledRoutes` | array | Approved subnet routes |
| `advertisedRoutes` | array | Requested subnet routes |
| `isExternal` | boolean | Whether the device is external |
| `updateAvailable` | boolean | Whether an update is available |
| `machineKey` | string | Machine key |
| `nodeKey` | string | Node key |
### `tailscale_delete_device`
Remove a device from the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the device was successfully deleted |
| `deviceId` | string | ID of the deleted device |
### `tailscale_authorize_device`
Authorize or deauthorize a device on the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID to authorize |
| `authorized` | boolean | Yes | Whether to authorize \(true\) or deauthorize \(false\) the device |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the operation succeeded |
| `deviceId` | string | Device ID |
| `authorized` | boolean | Authorization status after the operation |
### `tailscale_set_device_tags`
Set tags on a device in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
| `tags` | string | Yes | Comma-separated list of tags \(e.g., "tag:server,tag:production"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the tags were successfully set |
| `deviceId` | string | Device ID |
| `tags` | array | Tags set on the device |
### `tailscale_get_device_routes`
Get the subnet routes for a device
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `advertisedRoutes` | array | Subnet routes the device is advertising |
| `enabledRoutes` | array | Subnet routes that are approved/enabled |
### `tailscale_set_device_routes`
Set the enabled subnet routes for a device
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
| `routes` | string | Yes | Comma-separated list of subnet routes to enable \(e.g., "10.0.0.0/24,192.168.1.0/24"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `advertisedRoutes` | array | Subnet routes the device is advertising |
| `enabledRoutes` | array | Subnet routes that are now enabled |
### `tailscale_update_device_key`
Enable or disable key expiry on a device
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
| `keyExpiryDisabled` | boolean | Yes | Whether to disable key expiry \(true\) or enable it \(false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the operation succeeded |
| `deviceId` | string | Device ID |
| `keyExpiryDisabled` | boolean | Whether key expiry is now disabled |
### `tailscale_list_dns_nameservers`
Get the DNS nameservers configured for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dns` | array | List of DNS nameserver addresses |
| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_set_dns_nameservers`
Set the DNS nameservers for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `dns` | string | Yes | Comma-separated list of DNS nameserver IP addresses \(e.g., "8.8.8.8,8.8.4.4"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dns` | array | Updated list of DNS nameserver addresses |
### `tailscale_get_dns_preferences`
Get the DNS preferences for the tailnet including MagicDNS status
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_set_dns_preferences`
Set DNS preferences for the tailnet (enable/disable MagicDNS)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `magicDNS` | boolean | Yes | Whether to enable \(true\) or disable \(false\) MagicDNS |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `magicDNS` | boolean | Updated MagicDNS status |
### `tailscale_get_dns_searchpaths`
Get the DNS search paths configured for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searchPaths` | array | List of DNS search path domains |
### `tailscale_set_dns_searchpaths`
Set the DNS search paths for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `searchPaths` | string | Yes | Comma-separated list of DNS search path domains \(e.g., "corp.example.com,internal.example.com"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searchPaths` | array | Updated list of DNS search path domains |
### `tailscale_list_users`
List all users in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of users in the tailnet |
| ↳ `id` | string | User ID |
| ↳ `displayName` | string | Display name |
| ↳ `loginName` | string | Login name / email |
| ↳ `profilePicURL` | string | Profile picture URL |
| ↳ `role` | string | User role \(owner, admin, member, etc.\) |
| ↳ `status` | string | User status \(active, suspended, etc.\) |
| ↳ `type` | string | User type \(member, shared, tagged\) |
| ↳ `created` | string | Creation timestamp |
| ↳ `lastSeen` | string | Last seen timestamp |
| ↳ `deviceCount` | number | Number of devices owned by user |
| `count` | number | Total number of users |
### `tailscale_create_auth_key`
Create a new auth key for the tailnet to pre-authorize devices
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `reusable` | boolean | No | Whether the key can be used more than once |
| `ephemeral` | boolean | No | Whether devices authenticated with this key are ephemeral |
| `preauthorized` | boolean | No | Whether devices are pre-authorized \(skip manual approval\) |
| `tags` | string | Yes | Comma-separated list of tags for devices using this key \(e.g., "tag:server,tag:prod"\) |
| `description` | string | No | Description for the auth key |
| `expirySeconds` | number | No | Key expiry time in seconds \(default: 90 days\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Auth key ID |
| `key` | string | The auth key value \(only shown once at creation\) |
| `description` | string | Key description |
| `created` | string | Creation timestamp |
| `expires` | string | Expiration timestamp |
| `revoked` | string | Revocation timestamp \(empty if not revoked\) |
| `capabilities` | object | Key capabilities |
| ↳ `reusable` | boolean | Whether the key is reusable |
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
| ↳ `tags` | array | Tags applied to devices using this key |
### `tailscale_list_auth_keys`
List all auth keys in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `keys` | array | List of auth keys |
| ↳ `id` | string | Auth key ID |
| ↳ `description` | string | Key description |
| ↳ `created` | string | Creation timestamp |
| ↳ `expires` | string | Expiration timestamp |
| ↳ `revoked` | string | Revocation timestamp |
| ↳ `capabilities` | object | Key capabilities |
| ↳ `reusable` | boolean | Whether the key is reusable |
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
| ↳ `tags` | array | Tags applied to devices |
| `count` | number | Total number of auth keys |
### `tailscale_get_auth_key`
Get details of a specific auth key
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `keyId` | string | Yes | Auth key ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Auth key ID |
| `description` | string | Key description |
| `created` | string | Creation timestamp |
| `expires` | string | Expiration timestamp |
| `revoked` | string | Revocation timestamp |
| `capabilities` | object | Key capabilities |
| ↳ `reusable` | boolean | Whether the key is reusable |
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
| ↳ `tags` | array | Tags applied to devices using this key |
### `tailscale_delete_auth_key`
Revoke and delete an auth key
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `keyId` | string | Yes | Auth key ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the auth key was successfully deleted |
| `keyId` | string | ID of the deleted auth key |
### `tailscale_get_acl`
Get the current ACL policy for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `acl` | string | ACL policy as JSON string |
| `etag` | string | ETag for the current ACL version \(use with If-Match header for updates\) |

View File

@@ -28,6 +28,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.

View File

@@ -45,6 +45,7 @@ import {
EnrichSoIcon,
EvernoteIcon,
ExaAIIcon,
ExtendIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
@@ -91,6 +92,7 @@ import {
KalshiIcon,
KetchIcon,
LangsmithIcon,
LaunchDarklyIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
@@ -140,6 +142,7 @@ import {
S3Icon,
SalesforceIcon,
SearchIcon,
SecretsManagerIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -155,6 +158,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -221,6 +225,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
extend_v2: ExtendIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
@@ -269,6 +274,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -317,6 +323,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
secrets_manager: SecretsManagerIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
@@ -333,6 +340,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
stripe: StripeIcon,
stt_v2: STTIcon,
supabase: SupabaseIcon,
tailscale: TailscaleIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract_v2: TextractIcon,

View File

@@ -926,6 +926,10 @@
"name": "List Tasks",
"description": "List tasks in Attio, optionally filtered by record, assignee, or completion status"
},
{
"name": "Get Task",
"description": "Get a single task by ID from Attio"
},
{
"name": "Create Task",
"description": "Create a task in Attio"
@@ -1039,7 +1043,7 @@
"description": "Delete a webhook from Attio"
}
],
"operationCount": 40,
"operationCount": 41,
"triggers": [
{
"id": "attio_record_created",
@@ -1126,18 +1130,77 @@
"name": "Attio List Entry Deleted",
"description": "Trigger workflow when a list entry is deleted in Attio"
},
{
"id": "attio_list_created",
"name": "Attio List Created",
"description": "Trigger workflow when a list is created in Attio"
},
{
"id": "attio_list_updated",
"name": "Attio List Updated",
"description": "Trigger workflow when a list is updated in Attio"
},
{
"id": "attio_list_deleted",
"name": "Attio List Deleted",
"description": "Trigger workflow when a list is deleted in Attio"
},
{
"id": "attio_workspace_member_created",
"name": "Attio Workspace Member Created",
"description": "Trigger workflow when a new member is added to the Attio workspace"
},
{
"id": "attio_webhook",
"name": "Attio Webhook (All Events)",
"description": "Trigger workflow on any Attio webhook event"
}
],
"triggerCount": 18,
"triggerCount": 22,
"authType": "oauth",
"category": "tools",
"integrationType": "crm",
"tags": ["sales-engagement", "enrichment"]
},
{
"type": "secrets_manager",
"slug": "aws-secrets-manager",
"name": "AWS Secrets Manager",
"description": "Connect to AWS Secrets Manager",
"longDescription": "Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.",
"bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)",
"iconName": "SecretsManagerIcon",
"docsUrl": "https://docs.sim.ai/tools/secrets-manager",
"operations": [
{
"name": "Get Secret",
"description": "Retrieve a secret value from AWS Secrets Manager"
},
{
"name": "List Secrets",
"description": "List secrets stored in AWS Secrets Manager"
},
{
"name": "Create Secret",
"description": "Create a new secret in AWS Secrets Manager"
},
{
"name": "Update Secret",
"description": "Update the value of an existing secret in AWS Secrets Manager"
},
{
"name": "Delete Secret",
"description": "Delete a secret from AWS Secrets Manager"
}
],
"operationCount": 5,
"triggers": [],
"triggerCount": 0,
"authType": "none",
"category": "tools",
"integrationType": "developer-tools",
"tags": ["cloud", "secrets-management"]
},
{
"type": "textract_v2",
"slug": "aws-textract",
@@ -2939,6 +3002,24 @@
"integrationType": "search",
"tags": ["web-scraping", "enrichment"]
},
{
"type": "extend_v2",
"slug": "extend",
"name": "Extend",
"description": "Parse and extract content from documents",
"longDescription": "Integrate Extend AI into the workflow. Parse and extract structured content from documents or file references.",
"bgColor": "#000000",
"iconName": "ExtendIcon",
"docsUrl": "https://docs.sim.ai/tools/extend",
"operations": [],
"operationCount": 0,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "ai",
"tags": ["document-processing", "ocr"]
},
{
"type": "fathom",
"slug": "fathom",
@@ -6300,6 +6381,73 @@
"integrationType": "developer-tools",
"tags": ["monitoring", "llm", "data-analytics"]
},
{
"type": "launchdarkly",
"slug": "launchdarkly",
"name": "LaunchDarkly",
"description": "Manage feature flags with LaunchDarkly.",
"longDescription": "Integrate LaunchDarkly into your workflow. List, create, update, toggle, and delete feature flags. Manage projects, environments, segments, members, and audit logs. Requires API Key.",
"bgColor": "#191919",
"iconName": "LaunchDarklyIcon",
"docsUrl": "https://docs.sim.ai/tools/launchdarkly",
"operations": [
{
"name": "List Flags",
"description": "List feature flags in a LaunchDarkly project."
},
{
"name": "Get Flag",
"description": "Get a single feature flag by key from a LaunchDarkly project."
},
{
"name": "Create Flag",
"description": "Create a new feature flag in a LaunchDarkly project."
},
{
"name": "Update Flag",
"description": "Update a feature flag metadata (name, description, tags, temporary, archived) using semantic patch."
},
{
"name": "Toggle Flag",
"description": "Toggle a feature flag on or off in a specific LaunchDarkly environment."
},
{
"name": "Delete Flag",
"description": "Delete a feature flag from a LaunchDarkly project."
},
{
"name": "Get Flag Status",
"description": "Get the status of a feature flag across environments (active, inactive, launched, etc.)."
},
{
"name": "List Projects",
"description": "List all projects in your LaunchDarkly account."
},
{
"name": "List Environments",
"description": "List environments in a LaunchDarkly project."
},
{
"name": "List Segments",
"description": "List user segments in a LaunchDarkly project and environment."
},
{
"name": "List Members",
"description": "List account members in your LaunchDarkly organization."
},
{
"name": "Get Audit Log",
"description": "List audit log entries from your LaunchDarkly account."
}
],
"operationCount": 12,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "developer-tools",
"tags": ["feature-flags", "ci-cd"]
},
{
"type": "lemlist",
"slug": "lemlist",
@@ -10482,6 +10630,105 @@
"integrationType": "databases",
"tags": ["cloud", "data-warehouse", "vector-search"]
},
{
"type": "tailscale",
"slug": "tailscale",
"name": "Tailscale",
"description": "Manage devices and network settings in your Tailscale tailnet",
"longDescription": "Interact with the Tailscale API to manage devices, DNS, ACLs, auth keys, users, and routes across your tailnet.",
"bgColor": "#2E2D2D",
"iconName": "TailscaleIcon",
"docsUrl": "https://docs.sim.ai/tools/tailscale",
"operations": [
{
"name": "List Devices",
"description": "List all devices in the tailnet"
},
{
"name": "Get Device",
"description": "Get details of a specific device by ID"
},
{
"name": "Delete Device",
"description": "Remove a device from the tailnet"
},
{
"name": "Authorize Device",
"description": "Authorize or deauthorize a device on the tailnet"
},
{
"name": "Set Device Tags",
"description": "Set tags on a device in the tailnet"
},
{
"name": "Get Device Routes",
"description": "Get the subnet routes for a device"
},
{
"name": "Set Device Routes",
"description": "Set the enabled subnet routes for a device"
},
{
"name": "Update Device Key",
"description": "Enable or disable key expiry on a device"
},
{
"name": "List DNS Nameservers",
"description": "Get the DNS nameservers configured for the tailnet"
},
{
"name": "Set DNS Nameservers",
"description": "Set the DNS nameservers for the tailnet"
},
{
"name": "Get DNS Preferences",
"description": "Get the DNS preferences for the tailnet including MagicDNS status"
},
{
"name": "Set DNS Preferences",
"description": "Set DNS preferences for the tailnet (enable/disable MagicDNS)"
},
{
"name": "Get DNS Search Paths",
"description": "Get the DNS search paths configured for the tailnet"
},
{
"name": "Set DNS Search Paths",
"description": "Set the DNS search paths for the tailnet"
},
{
"name": "List Users",
"description": "List all users in the tailnet"
},
{
"name": "Create Auth Key",
"description": "Create a new auth key for the tailnet to pre-authorize devices"
},
{
"name": "List Auth Keys",
"description": "List all auth keys in the tailnet"
},
{
"name": "Get Auth Key",
"description": "Get details of a specific auth key"
},
{
"name": "Delete Auth Key",
"description": "Revoke and delete an auth key"
},
{
"name": "Get ACL",
"description": "Get the current ACL policy for the tailnet"
}
],
"operationCount": 20,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "security",
"tags": ["monitoring"]
},
{
"type": "tavily",
"slug": "tavily",

View File

@@ -13,10 +13,12 @@ import type {
import { validateExercise } from '@/lib/academy/validation'
import { cn } from '@/lib/core/utils/cn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
import { getBlock } from '@/blocks/registry'
import { workflowKeys } from '@/hooks/queries/workflows'
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
import { useExecutionStore } from '@/stores/execution/store'
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
@@ -218,8 +220,13 @@ export function SandboxCanvasProvider({
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
useWorkflowRegistry.setState((state) => ({
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
const qc = getQueryClient()
const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
const cached = qc.getQueryData<WorkflowMetadata[]>(cacheKey) ?? []
qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata])
useWorkflowRegistry.setState({
activeWorkflowId: workflowId,
hydration: {
phase: 'ready',
@@ -228,7 +235,7 @@ export function SandboxCanvasProvider({
requestId: null,
error: null,
},
}))
})
logger.info('Sandbox stores hydrated', { workflowId })
setIsReady(true)
@@ -262,17 +269,21 @@ export function SandboxCanvasProvider({
unsubWorkflow()
unsubSubBlock()
unsubExecution()
useWorkflowRegistry.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflows
return {
workflows: rest,
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
hydration:
state.hydration.workflowId === workflowId
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
: state.hydration,
}
})
const cleanupQc = getQueryClient()
const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
const cleanupCached = cleanupQc.getQueryData<WorkflowMetadata[]>(cleanupKey) ?? []
cleanupQc.setQueryData(
cleanupKey,
cleanupCached.filter((w) => w.id !== workflowId)
)
useWorkflowRegistry.setState((state) => ({
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
hydration:
state.hydration.workflowId === workflowId
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
: state.hydration,
}))
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
useSubBlockStore.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflowValues

View File

@@ -457,11 +457,8 @@ describe('Knowledge Base Documents API Route', () => {
},
],
processingOptions: {
chunkSize: 1024,
minCharactersPerChunk: 100,
recipe: 'default',
lang: 'en',
chunkOverlap: 200,
},
}
@@ -533,11 +530,8 @@ describe('Knowledge Base Documents API Route', () => {
},
],
processingOptions: {
chunkSize: 50, // Invalid: too small
minCharactersPerChunk: 0, // Invalid: too small
recipe: 'default',
lang: 'en',
chunkOverlap: 1000, // Invalid: too large
},
}

View File

@@ -38,26 +38,14 @@ const CreateDocumentSchema = z.object({
documentTagsData: z.string().optional(),
})
/**
* Schema for bulk document creation with processing options
*
* Processing options units:
* - chunkSize: tokens (1 token ≈ 4 characters)
* - minCharactersPerChunk: characters
* - chunkOverlap: characters
*/
const BulkCreateDocumentsSchema = z.object({
documents: z.array(CreateDocumentSchema),
processingOptions: z.object({
/** Maximum chunk size in tokens (1 token ≈ 4 characters) */
chunkSize: z.number().min(100).max(4000),
/** Minimum chunk size in characters */
minCharactersPerChunk: z.number().min(1).max(2000),
recipe: z.string(),
lang: z.string(),
/** Overlap between chunks in characters */
chunkOverlap: z.number().min(0).max(500),
}),
processingOptions: z
.object({
recipe: z.string().optional(),
lang: z.string().optional(),
})
.optional(),
bulk: z.literal(true),
})
@@ -246,8 +234,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
knowledgeBaseId,
documentsCount: createdDocuments.length,
uploadType: 'bulk',
chunkSize: validatedData.processingOptions.chunkSize,
recipe: validatedData.processingOptions.recipe,
recipe: validatedData.processingOptions?.recipe,
})
} catch (_e) {
// Silently fail
@@ -256,7 +243,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
validatedData.processingOptions,
validatedData.processingOptions ?? {},
requestId
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)

View File

@@ -25,13 +25,12 @@ const UpsertDocumentSchema = z.object({
fileSize: z.number().min(1, 'File size must be greater than 0'),
mimeType: z.string().min(1, 'MIME type is required'),
documentTagsData: z.string().optional(),
processingOptions: z.object({
chunkSize: z.number().min(100).max(4000),
minCharactersPerChunk: z.number().min(1).max(2000),
recipe: z.string(),
lang: z.string(),
chunkOverlap: z.number().min(0).max(500),
}),
processingOptions: z
.object({
recipe: z.string().optional(),
lang: z.string().optional(),
})
.optional(),
workflowId: z.string().optional(),
})
@@ -166,7 +165,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
validatedData.processingOptions,
validatedData.processingOptions ?? {},
requestId
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
@@ -178,8 +177,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
chunkSize: validatedData.processingOptions.chunkSize,
recipe: validatedData.processingOptions.recipe,
recipe: validatedData.processingOptions?.recipe,
})
} catch (_e) {
// Silently fail

View File

@@ -38,15 +38,23 @@ interface SyncResult {
updatedWorkflowIds: string[]
}
interface ServerMetadata {
url?: string
name?: string
}
/**
* Syncs tool schemas from discovered MCP tools to all workflow blocks using those tools.
* Returns the count and IDs of updated workflows.
* Syncs tool schemas and server metadata from discovered MCP tools to all
* workflow blocks using those tools. Updates stored serverUrl/serverName
* when the server's details have changed, preventing stale badges after
* a server URL edit.
*/
async function syncToolSchemasToWorkflows(
workspaceId: string,
serverId: string,
tools: McpTool[],
requestId: string
requestId: string,
serverMeta?: ServerMetadata
): Promise<SyncResult> {
const toolsByName = new Map(tools.map((t) => [t.name, t]))
@@ -94,7 +102,10 @@ async function syncToolSchemasToWorkflows(
const schemasMatch = JSON.stringify(tool.schema) === JSON.stringify(newSchema)
if (!schemasMatch) {
const urlChanged = serverMeta?.url != null && tool.params.serverUrl !== serverMeta.url
const nameChanged = serverMeta?.name != null && tool.params.serverName !== serverMeta.name
if (!schemasMatch || urlChanged || nameChanged) {
hasUpdates = true
const validParamKeys = new Set(Object.keys(newSchema.properties || {}))
@@ -106,6 +117,9 @@ async function syncToolSchemasToWorkflows(
}
}
if (urlChanged) cleanedParams.serverUrl = serverMeta.url
if (nameChanged) cleanedParams.serverName = serverMeta.name
return { ...tool, schema: newSchema, params: cleanedParams }
}
@@ -188,7 +202,8 @@ export const POST = withMcpAuth<{ id: string }>('read')(
workspaceId,
serverId,
discoveredTools,
requestId
requestId,
{ url: server.url ?? undefined, name: server.name ?? undefined }
)
} catch (error) {
connectionStatus = 'error'

View File

@@ -0,0 +1,93 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getBYOKKey } from '@/lib/api-key/byok'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'
const logger = createLogger('FireworksModelsAPI')
interface FireworksModel {
id: string
object?: string
created?: number
owned_by?: string
}
interface FireworksModelsResponse {
data: FireworksModel[]
object?: string
}
export async function GET(request: NextRequest) {
if (isProviderBlacklisted('fireworks')) {
logger.info('Fireworks provider is blacklisted, returning empty models')
return NextResponse.json({ models: [] })
}
let apiKey: string | undefined
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
if (workspaceId) {
const session = await getSession()
if (session?.user?.id) {
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission) {
const byokResult = await getBYOKKey(workspaceId, 'fireworks')
if (byokResult) {
apiKey = byokResult.apiKey
}
}
}
}
if (!apiKey) {
apiKey = env.FIREWORKS_API_KEY
}
if (!apiKey) {
logger.info('No Fireworks API key available, returning empty models')
return NextResponse.json({ models: [] })
}
try {
const response = await fetch('https://api.fireworks.ai/inference/v1/models', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
})
if (!response.ok) {
logger.warn('Failed to fetch Fireworks models', {
status: response.status,
statusText: response.statusText,
})
return NextResponse.json({ models: [] })
}
const data = (await response.json()) as FireworksModelsResponse
const allModels: string[] = []
for (const model of data.data ?? []) {
allModels.push(`fireworks/${model.id}`)
}
const uniqueModels = Array.from(new Set(allModels))
const models = filterBlacklistedModels(uniqueModels)
logger.info('Successfully fetched Fireworks models', {
count: models.length,
filtered: uniqueModels.length - models.length,
})
return NextResponse.json({ models })
} catch (error) {
logger.error('Error fetching Fireworks models', {
error: error instanceof Error ? error.message : 'Unknown error',
})
return NextResponse.json({ models: [] })
}
}

View File

@@ -146,7 +146,11 @@ export async function GET(request: NextRequest) {
})
} else {
jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
metadata: {
workflowId: schedule.workflowId ?? undefined,
workspaceId: resolvedWorkspaceId ?? undefined,
correlation,
},
})
}
logger.info(

View File

@@ -0,0 +1,188 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('ExtendParseAPI')
const ExtendParseSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
filePath: z.string().optional(),
file: RawFileInputSchema.optional(),
outputFormat: z.enum(['markdown', 'spatial']).optional(),
chunking: z.enum(['page', 'document', 'section']).optional(),
engine: z.enum(['parse_performance', 'parse_light']).optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Extend parse attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Unauthorized',
},
{ status: 401 }
)
}
const userId = authResult.userId
const body = await request.json()
const validatedData = ExtendParseSchema.parse(body)
logger.info(`[${requestId}] Extend parse request`, {
fileName: validatedData.file?.name,
filePath: validatedData.filePath,
isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false,
userId,
})
const resolution = await resolveFileInputToUrl({
file: validatedData.file,
filePath: validatedData.filePath,
userId,
requestId,
logger,
})
if (resolution.error) {
return NextResponse.json(
{ success: false, error: resolution.error.message },
{ status: resolution.error.status }
)
}
const fileUrl = resolution.fileUrl
if (!fileUrl) {
return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 })
}
const extendBody: Record<string, unknown> = {
file: { fileUrl },
}
const config: Record<string, unknown> = {}
if (validatedData.outputFormat) {
config.target = validatedData.outputFormat
}
if (validatedData.chunking) {
config.chunkingStrategy = { type: validatedData.chunking }
}
if (validatedData.engine) {
config.engine = validatedData.engine
}
if (Object.keys(config).length > 0) {
extendBody.config = config
}
const extendEndpoint = 'https://api.extend.ai/parse'
const extendValidation = await validateUrlWithDNS(extendEndpoint, 'Extend API URL')
if (!extendValidation.isValid) {
logger.error(`[${requestId}] Extend API URL validation failed`, {
error: extendValidation.error,
})
return NextResponse.json(
{
success: false,
error: 'Failed to reach Extend API',
},
{ status: 502 }
)
}
const extendResponse = await secureFetchWithPinnedIP(
extendEndpoint,
extendValidation.resolvedIP!,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${validatedData.apiKey}`,
'x-extend-api-version': '2025-04-21',
},
body: JSON.stringify(extendBody),
}
)
if (!extendResponse.ok) {
const errorText = await extendResponse.text()
logger.error(`[${requestId}] Extend API error:`, errorText)
let clientError = `Extend API error: ${extendResponse.statusText || extendResponse.status}`
try {
const parsedError = JSON.parse(errorText)
if (parsedError?.message || parsedError?.error) {
clientError = (parsedError.message ?? parsedError.error) as string
}
} catch {
// errorText is not JSON; keep generic message
}
return NextResponse.json(
{
success: false,
error: clientError,
},
{ status: extendResponse.status }
)
}
const extendData = (await extendResponse.json()) as Record<string, unknown>
logger.info(`[${requestId}] Extend parse successful`)
return NextResponse.json({
success: true,
output: {
id: extendData.id ?? null,
status: extendData.status ?? 'PROCESSED',
chunks: extendData.chunks ?? [],
blocks: extendData.blocks ?? [],
pageCount: extendData.pageCount ?? extendData.page_count ?? null,
creditsUsed: extendData.creditsUsed ?? extendData.credits_used ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error in Extend parse:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,65 @@
import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSecret, createSecretsManagerClient } from '../utils'
const logger = createLogger('SecretsManagerCreateSecretAPI')
const CreateSecretSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
name: z.string().min(1, 'Secret name is required'),
secretValue: z.string().min(1, 'Secret value is required'),
description: z.string().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = CreateSecretSchema.parse(body)
logger.info(`[${requestId}] Creating secret ${params.name}`)
const client = createSecretsManagerClient({
region: params.region,
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
})
try {
const result = await createSecret(client, params.name, params.secretValue, params.description)
logger.info(`[${requestId}] Secret created: ${result.name}`)
return NextResponse.json({
message: `Secret "${result.name}" created successfully`,
...result,
})
} finally {
client.destroy()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Failed to create secret:`, error)
return NextResponse.json({ error: `Failed to create secret: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,71 @@
import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSecretsManagerClient, deleteSecret } from '../utils'
const logger = createLogger('SecretsManagerDeleteSecretAPI')
const DeleteSecretSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
secretId: z.string().min(1, 'Secret ID is required'),
recoveryWindowInDays: z.number().min(7).max(30).nullish(),
forceDelete: z.boolean().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = DeleteSecretSchema.parse(body)
logger.info(`[${requestId}] Deleting secret ${params.secretId}`)
const client = createSecretsManagerClient({
region: params.region,
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
})
try {
const result = await deleteSecret(
client,
params.secretId,
params.recoveryWindowInDays,
params.forceDelete
)
const action = params.forceDelete ? 'permanently deleted' : 'scheduled for deletion'
logger.info(`[${requestId}] Secret ${action}: ${result.name}`)
return NextResponse.json({
message: `Secret "${result.name}" ${action}`,
...result,
})
} finally {
client.destroy()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Failed to delete secret:`, error)
return NextResponse.json({ error: `Failed to delete secret: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSecretsManagerClient, getSecretValue } from '../utils'
const logger = createLogger('SecretsManagerGetSecretAPI')
const GetSecretSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
secretId: z.string().min(1, 'Secret ID is required'),
versionId: z.string().nullish(),
versionStage: z.string().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = GetSecretSchema.parse(body)
logger.info(`[${requestId}] Retrieving secret ${params.secretId}`)
const client = createSecretsManagerClient({
region: params.region,
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
})
try {
const result = await getSecretValue(
client,
params.secretId,
params.versionId,
params.versionStage
)
logger.info(`[${requestId}] Secret retrieved successfully`)
return NextResponse.json(result)
} finally {
client.destroy()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Failed to retrieve secret:`, error)
return NextResponse.json(
{ error: `Failed to retrieve secret: ${errorMessage}` },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,61 @@
import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSecretsManagerClient, listSecrets } from '../utils'
const logger = createLogger('SecretsManagerListSecretsAPI')
const ListSecretsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
maxResults: z.number().min(1).max(100).nullish(),
nextToken: z.string().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = ListSecretsSchema.parse(body)
logger.info(`[${requestId}] Listing secrets`)
const client = createSecretsManagerClient({
region: params.region,
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
})
try {
const result = await listSecrets(client, params.maxResults, params.nextToken)
logger.info(`[${requestId}] Listed ${result.count} secrets`)
return NextResponse.json(result)
} finally {
client.destroy()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Failed to list secrets:`, error)
return NextResponse.json({ error: `Failed to list secrets: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSecretsManagerClient, updateSecretValue } from '../utils'
const logger = createLogger('SecretsManagerUpdateSecretAPI')
const UpdateSecretSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
secretId: z.string().min(1, 'Secret ID is required'),
secretValue: z.string().min(1, 'Secret value is required'),
description: z.string().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const params = UpdateSecretSchema.parse(body)
logger.info(`[${requestId}] Updating secret ${params.secretId}`)
const client = createSecretsManagerClient({
region: params.region,
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
})
try {
const result = await updateSecretValue(
client,
params.secretId,
params.secretValue,
params.description
)
logger.info(`[${requestId}] Secret updated: ${result.name}`)
return NextResponse.json({
message: `Secret "${result.name}" updated successfully`,
...result,
})
} finally {
client.destroy()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Failed to update secret:`, error)
return NextResponse.json({ error: `Failed to update secret: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,140 @@
import {
CreateSecretCommand,
DeleteSecretCommand,
GetSecretValueCommand,
ListSecretsCommand,
SecretsManagerClient,
UpdateSecretCommand,
} from '@aws-sdk/client-secrets-manager'
import type { SecretsManagerConnectionConfig } from '@/tools/secrets_manager/types'
export function createSecretsManagerClient(
config: SecretsManagerConnectionConfig
): SecretsManagerClient {
return new SecretsManagerClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}
export async function getSecretValue(
client: SecretsManagerClient,
secretId: string,
versionId?: string | null,
versionStage?: string | null
) {
const command = new GetSecretValueCommand({
SecretId: secretId,
...(versionId ? { VersionId: versionId } : {}),
...(versionStage ? { VersionStage: versionStage } : {}),
})
const response = await client.send(command)
if (!response.SecretString && response.SecretBinary) {
throw new Error(
'Secret is stored as binary (SecretBinary). This integration only supports string secrets.'
)
}
return {
name: response.Name ?? '',
secretValue: response.SecretString ?? '',
arn: response.ARN ?? '',
versionId: response.VersionId ?? '',
versionStages: response.VersionStages ?? [],
createdDate: response.CreatedDate?.toISOString() ?? null,
}
}
export async function listSecrets(
client: SecretsManagerClient,
maxResults?: number | null,
nextToken?: string | null
) {
const command = new ListSecretsCommand({
...(maxResults ? { MaxResults: maxResults } : {}),
...(nextToken ? { NextToken: nextToken } : {}),
})
const response = await client.send(command)
const secrets = (response.SecretList ?? []).map((secret) => ({
name: secret.Name ?? '',
arn: secret.ARN ?? '',
description: secret.Description ?? null,
createdDate: secret.CreatedDate?.toISOString() ?? null,
lastChangedDate: secret.LastChangedDate?.toISOString() ?? null,
lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null,
rotationEnabled: secret.RotationEnabled ?? false,
tags: secret.Tags?.map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [],
}))
return {
secrets,
nextToken: response.NextToken ?? null,
count: secrets.length,
}
}
export async function createSecret(
client: SecretsManagerClient,
name: string,
secretValue: string,
description?: string | null
) {
const command = new CreateSecretCommand({
Name: name,
SecretString: secretValue,
...(description ? { Description: description } : {}),
})
const response = await client.send(command)
return {
name: response.Name ?? '',
arn: response.ARN ?? '',
versionId: response.VersionId ?? '',
}
}
export async function updateSecretValue(
client: SecretsManagerClient,
secretId: string,
secretValue: string,
description?: string | null
) {
const command = new UpdateSecretCommand({
SecretId: secretId,
SecretString: secretValue,
...(description ? { Description: description } : {}),
})
const response = await client.send(command)
return {
name: response.Name ?? '',
arn: response.ARN ?? '',
versionId: response.VersionId ?? '',
}
}
export async function deleteSecret(
client: SecretsManagerClient,
secretId: string,
recoveryWindowInDays?: number | null,
forceDelete?: boolean | null
) {
const command = new DeleteSecretCommand({
SecretId: secretId,
...(forceDelete ? { ForceDeleteWithoutRecovery: true } : {}),
...(!forceDelete && recoveryWindowInDays ? { RecoveryWindowInDays: recoveryWindowInDays } : {}),
})
const response = await client.send(command)
return {
name: response.Name ?? '',
arn: response.ARN ?? '',
deletionDate: response.DeletionDate?.toISOString() ?? null,
}
}

View File

@@ -187,8 +187,6 @@ export async function POST(request: NextRequest, { params }: DocumentsRouteParam
requestId
)
const chunkingConfig = result.kb.chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }
const documentData: DocumentData = {
documentId: newDocument.id,
filename: file.name,
@@ -197,18 +195,7 @@ export async function POST(request: NextRequest, { params }: DocumentsRouteParam
mimeType: contentType,
}
processDocumentsWithQueue(
[documentData],
knowledgeBaseId,
{
chunkSize: chunkingConfig.maxSize,
minCharactersPerChunk: chunkingConfig.minSize,
chunkOverlap: chunkingConfig.overlap,
recipe: 'default',
lang: 'en',
},
requestId
).catch(() => {
processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => {
// Processing errors are logged internally
})

View File

@@ -162,7 +162,13 @@ export async function POST(req: Request) {
if (isTriggerDevEnabled) {
try {
const handle = await tasks.trigger('mothership-inbox-execution', { taskId })
const handle = await tasks.trigger(
'mothership-inbox-execution',
{ taskId },
{
tags: [`workspaceId:${result.id}`, `taskId:${taskId}`],
}
)
await db
.update(mothershipInboxTask)
.set({ triggerJobId: handle.id })

View File

@@ -237,7 +237,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
},
})
: await jobQueue!.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
metadata: { workflowId, workspaceId, userId, correlation },
})
asyncLogger.info('Queued async workflow execution', { jobId })

View File

@@ -18,6 +18,7 @@ const VALID_PROVIDERS = [
'anthropic',
'google',
'mistral',
'fireworks',
'firecrawl',
'exa',
'serper',

View File

@@ -1,8 +1,4 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Files } from '../files'
export const metadata: Metadata = {
@@ -10,30 +6,4 @@ export const metadata: Metadata = {
robots: { index: false },
}
interface FileDetailPageProps {
params: Promise<{
workspaceId: string
fileId: string
}>
}
export default async function FileDetailPage({ params }: FileDetailPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideFilesTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Files />
}
export default Files

View File

@@ -1,15 +1,22 @@
'use client'
import { createLogger } from '@sim/logger'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useParams } from 'next/navigation'
import { useWorkspaceFileRecord } from '@/hooks/queries/workspace-files'
const logger = createLogger('FileViewer')
interface FileViewerProps {
file: WorkspaceFileRecord
}
export function FileViewer() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const fileId = params?.fileId as string
const { data: file, isLoading } = useWorkspaceFileRecord(workspaceId, fileId)
if (isLoading || !file) {
return null
}
export function FileViewer({ file }: FileViewerProps) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -18,7 +25,7 @@ export function FileViewer({ file }: FileViewerProps) {
src={serveUrl}
className='h-full w-full border-0'
title={file.name}
onError={(e) => {
onError={() => {
logger.error(`Failed to load file: ${file.name}`)
}}
/>

View File

@@ -1,46 +1,9 @@
import type { Metadata } from 'next'
import { redirect, unstable_rethrow } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer'
import { FileViewer } from './file-viewer'
export const metadata: Metadata = {
title: 'File',
robots: { index: false },
}
interface FileViewerPageProps {
params: Promise<{
workspaceId: string
fileId: string
}>
}
export default async function FileViewerPage({ params }: FileViewerPageProps) {
const { workspaceId, fileId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect(`/workspace/${workspaceId}`)
}
let fileRecord: Awaited<ReturnType<typeof getWorkspaceFile>>
try {
fileRecord = await getWorkspaceFile(workspaceId, fileId)
} catch (error) {
unstable_rethrow(error)
redirect(`/workspace/${workspaceId}`)
}
if (!fileRecord) {
redirect(`/workspace/${workspaceId}`)
}
return <FileViewer file={fileRecord} />
}
export default FileViewer

View File

@@ -75,6 +75,7 @@ import {
} from '@/hooks/queries/workspace-files'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
@@ -136,6 +137,13 @@ export function Files() {
const fileIdFromRoute =
typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null
const userPermissions = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (permissionConfig.hideFilesTab) {
router.replace(`/workspace/${workspaceId}`)
}
}, [permissionConfig.hideFilesTab, router, workspaceId])
const { data: files = [], isLoading, error } = useWorkspaceFiles(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)

View File

@@ -1,8 +1,4 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Files } from './files'
export const metadata: Metadata = {
@@ -10,29 +6,4 @@ export const metadata: Metadata = {
robots: { index: false },
}
interface FilesPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function FilesPage({ params }: FilesPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideFilesTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Files />
}
export default Files

View File

@@ -47,7 +47,7 @@ export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>
): AvailableItemsByType[] {
const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)

View File

@@ -37,6 +37,7 @@ import {
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useExecutionStore } from '@/stores/execution/store'
@@ -375,15 +376,16 @@ interface EmbeddedWorkflowProps {
}
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId]))
const isMetadataLoaded = useWorkflowRegistry(
(state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading'
const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId)
const workflowExists = useMemo(
() => (workflowList ?? []).some((w) => w.id === workflowId),
[workflowList, workflowId]
)
const hasLoadError = useWorkflowRegistry(
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
)
if (!isMetadataLoaded) return LOADING_SKELETON
if (isWorkflowsPending) return LOADING_SKELETON
if (!workflowExists || hasLoadError) {
return (

View File

@@ -1,7 +1,8 @@
'use client'
import type { ElementType, ReactNode } from 'react'
import { type ElementType, type ReactNode, useMemo } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import {
Database,
File as FileIcon,
@@ -17,9 +18,9 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { workflowKeys } from '@/hooks/queries/workflows'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useWorkflows } from '@/hooks/queries/workflows'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DropdownItemRenderProps {
item: { id: string; name: string; [key: string]: unknown }
@@ -34,7 +35,12 @@ export interface ResourceTypeConfig {
}
function WorkflowTabSquare({ workflowId, className }: { workflowId: string; className?: string }) {
const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888')
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowList } = useWorkflows(workspaceId)
const color = useMemo(() => {
const wf = (workflowList ?? []).find((w) => w.id === workflowId)
return wf?.color ?? '#888'
}, [workflowList, workflowId])
return (
<div
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
@@ -157,8 +163,8 @@ const RESOURCE_INVALIDATORS: Record<
qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
workflow: (qc, _wId) => {
qc.invalidateQueries({ queryKey: workflowKeys.lists() })
workflow: (qc, wId) => {
void invalidateWorkflowLists(qc, wId)
},
knowledgebase: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: knowledgeKeys.lists() })

View File

@@ -53,7 +53,7 @@ const PREVIEW_MODE_LABELS: Record<PreviewMode, string> = {
* tabs always reflect the latest name even after a rename.
*/
function useResourceNameLookup(workspaceId: string): Map<string, string> {
const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const { data: workflows = [] } = useWorkflows(workspaceId)
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)

View File

@@ -45,8 +45,8 @@ import {
computeMentionHighlightRanges,
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import type { ChatContext } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
@@ -122,6 +122,7 @@ export function UserInput({
onContextAdd,
}: UserInputProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
const { data: session } = useSession()
const [value, setValue] = useState(defaultValue)
const overlayRef = useRef<HTMLDivElement>(null)
@@ -617,7 +618,6 @@ export function UserInput({
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
@@ -639,7 +639,7 @@ export function UserInput({
case 'workflow':
case 'current_workflow': {
const wfId = (matchingCtx as { workflowId: string }).workflowId
const wfColor = useWorkflowRegistry.getState().workflows[wfId]?.color ?? '#888'
const wfColor = workflowsById[wfId]?.color ?? '#888'
mentionIconNode = (
<div
className='absolute inset-0 m-auto h-[12px] w-[12px] rounded-[3px] border-[2px]'
@@ -691,7 +691,7 @@ export function UserInput({
}
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [value, contextManagement.selectedContexts])
}, [value, contextManagement.selectedContexts, workflowsById])
return (
<div

View File

@@ -1,9 +1,11 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Database, Table as TableIcon } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflows } from '@/hooks/queries/workflows'
const USER_MESSAGE_CLASSES =
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-[430] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
@@ -44,12 +46,12 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men
}
function MentionHighlight({ context }: { context: ChatMessageContext }) {
const workflowColor = useWorkflowRegistry((state) => {
if (context.kind === 'workflow' || context.kind === 'current_workflow') {
return state.workflows[context.workflowId || '']?.color ?? null
}
return null
})
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowList } = useWorkflows(workspaceId)
const workflowColor = useMemo(() => {
if (context.kind !== 'workflow' && context.kind !== 'current_workflow') return null
return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null
}, [workflowList, context.kind, context.workflowId])
let icon: React.ReactNode = null
const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'

View File

@@ -21,27 +21,8 @@ import {
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
fetchChatHistory,
type StreamSnapshot,
type TaskChatHistory,
type TaskStoredContentBlock,
type TaskStoredFileAttachment,
type TaskStoredMessage,
type TaskStoredToolCall,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
import { useFolderStore } from '@/stores/folders/store'
import type { ChatContext } from '@/stores/panel'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type {
ChatMessage,
ChatMessageAttachment,
@@ -56,7 +37,30 @@ import type {
SSEPayload,
SSEPayloadData,
ToolCallStatus,
} from '../types'
} from '@/app/workspace/[workspaceId]/home/types'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
fetchChatHistory,
type StreamSnapshot,
type TaskChatHistory,
type TaskStoredContentBlock,
type TaskStoredFileAttachment,
type TaskStoredMessage,
type TaskStoredToolCall,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
import type { ChatContext } from '@/stores/panel'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
export interface UseChatReturn {
messages: ChatMessage[]
@@ -301,31 +305,37 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
return typeof payload.data === 'object' ? payload.data : undefined
}
/** Adds a workflow to the registry with a top-insertion sort order if it doesn't already exist. */
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean {
const registry = useWorkflowRegistry.getState()
if (registry.workflows[resourceId]) return false
const workflows = getWorkflows(workspaceId)
if (workflows.some((w) => w.id === resourceId)) return false
const sortOrder = getTopInsertionSortOrder(
registry.workflows,
useFolderStore.getState().folders,
Object.fromEntries(workflows.map((w) => [w.id, w])),
getFolderMap(workspaceId),
workspaceId,
null
)
useWorkflowRegistry.setState((state) => ({
workflows: {
...state.workflows,
[resourceId]: {
id: resourceId,
name: title,
lastModified: new Date(),
createdAt: new Date(),
color: getNextWorkflowColor(),
workspaceId,
folderId: null,
sortOrder,
},
},
}))
const newMetadata: WorkflowMetadata = {
id: resourceId,
name: title,
lastModified: new Date(),
createdAt: new Date(),
color: getNextWorkflowColor(),
workspaceId,
folderId: null,
sortOrder,
}
const queryClient = getQueryClient()
const key = workflowKeys.list(workspaceId, 'active')
queryClient.setQueryData<WorkflowMetadata[]>(key, (current) => {
const next = current ?? workflows
if (next.some((workflow) => workflow.id === resourceId)) {
return next
}
return [...next, newMetadata]
})
void invalidateWorkflowSelectors(queryClient, workspaceId)
return true
}
@@ -1253,7 +1263,7 @@ export function useChat(
? ((args as Record<string, unknown>).workflowId as string)
: useWorkflowRegistry.getState().activeWorkflowId
if (targetWorkflowId) {
const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId]
const meta = getWorkflowById(workspaceId, targetWorkflowId)
const wasAdded = addResource({
type: 'workflow',
id: targetWorkflowId,

View File

@@ -1,31 +1,8 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Home } from './home'
export const metadata: Metadata = {
title: 'Home',
}
interface HomePageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function HomePage({ params }: HomePageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
return <Home key='home' />
}
export default Home

View File

@@ -56,22 +56,11 @@ export function ChunkEditor({
const [savedContent, setSavedContent] = useState(chunkContent)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const prevChunkIdRef = useRef(chunk?.id)
const savedContentRef = useRef(chunkContent)
const editedContentRef = useRef(editedContent)
editedContentRef.current = editedContent
useEffect(() => {
if (isCreateMode) return
if (chunk?.id !== prevChunkIdRef.current) {
prevChunkIdRef.current = chunk?.id
savedContentRef.current = chunkContent
setSavedContent(chunkContent)
setEditedContent(chunkContent)
}
}, [isCreateMode, chunk?.id, chunkContent])
useEffect(() => {
if (isCreateMode || !chunk?.id) return
const controller = new AbortController()

View File

@@ -1,6 +1,6 @@
'use client'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
@@ -47,6 +47,7 @@ import {
useUpdateChunk,
useUpdateDocument,
} from '@/hooks/queries/kb/knowledge'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
const logger = createLogger('Document')
@@ -152,7 +153,7 @@ export function Document({
const [showTagsModal, setShowTagsModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 200)
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
const [activeSort, setActiveSort] = useState<{
column: string
@@ -168,11 +169,8 @@ export function Document({
chunks: initialChunks,
currentPage: initialPage,
totalPages: initialTotalPages,
hasNextPage: initialHasNextPage,
hasPrevPage: initialHasPrevPage,
goToPage: initialGoToPage,
error: initialError,
refreshChunks: initialRefreshChunks,
updateChunk: initialUpdateChunk,
isFetching: isFetchingChunks,
} = useDocumentChunks(
@@ -207,7 +205,9 @@ export function Document({
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(() => new Set())
// Inline editor state
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null)
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(() =>
searchParams.get('chunk')
)
const [isCreatingNewChunk, setIsCreatingNewChunk] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
@@ -217,27 +217,6 @@ export function Document({
const saveStatusRef = useRef<SaveStatus>('idle')
saveStatusRef.current = saveStatus
// Auto-select chunk from URL param on mount
const initialChunkParam = useRef(searchParams.get('chunk'))
useEffect(() => {
if (initialChunkParam.current) {
setSelectedChunkId(initialChunkParam.current)
initialChunkParam.current = null
}
}, [])
useEffect(() => {
const handler = setTimeout(() => {
startTransition(() => {
setDebouncedSearchQuery(searchQuery)
})
}, 200)
return () => {
clearTimeout(handler)
}
}, [searchQuery])
const isSearching = debouncedSearchQuery.trim().length > 0
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
const SEARCH_PAGE_SIZE = 50
@@ -259,8 +238,6 @@ export function Document({
const currentPage = showingSearch ? searchCurrentPage : initialPage
const totalPages = showingSearch ? searchTotalPages : initialTotalPages
const hasNextPage = showingSearch ? searchCurrentPage < searchTotalPages : initialHasNextPage
const hasPrevPage = showingSearch ? searchCurrentPage > 1 : initialHasPrevPage
// Keep refs to displayChunks and totalPages so polling callbacks can read fresh data
const displayChunksRef = useRef(displayChunks)
@@ -281,12 +258,11 @@ export function Document({
if (showingSearch) {
return
}
return await initialGoToPage(page)
return initialGoToPage(page)
},
[showingSearch, initialGoToPage]
)
const refreshChunks = showingSearch ? async () => {} : initialRefreshChunks
const updateChunk = showingSearch
? (_id: string, _updates: Record<string, unknown>) => {}
: initialUpdateChunk
@@ -309,7 +285,6 @@ export function Document({
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
menuRef,
handleContextMenu: baseHandleContextMenu,
closeMenu: closeContextMenu,
} = useContextMenu()
@@ -661,18 +636,11 @@ export function Document({
const chunk = displayChunks.find((c) => c.id === chunkId)
if (!chunk) return
const newEnabled = !chunk.enabled
updateChunk(chunkId, { enabled: newEnabled })
updateChunkMutation(
{
knowledgeBaseId,
documentId,
chunkId,
enabled: !chunk.enabled,
},
{
onSuccess: () => {
updateChunk(chunkId, { enabled: !chunk.enabled })
},
}
{ knowledgeBaseId, documentId, chunkId, enabled: newEnabled },
{ onError: () => updateChunk(chunkId, { enabled: chunk.enabled }) }
)
},
[displayChunks, knowledgeBaseId, documentId, updateChunk]

View File

@@ -62,7 +62,7 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useConnectorList } from '@/hooks/queries/kb/connectors'
import { isConnectorSyncingOrPending, useConnectorList } from '@/hooks/queries/kb/connectors'
import type { DocumentTagFilter } from '@/hooks/queries/kb/knowledge'
import {
useBulkDocumentOperation,
@@ -194,7 +194,7 @@ export function KnowledgeBase({
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
const userPermissions = useUserPermissionsContext()
const { mutate: updateDocumentMutation } = useUpdateDocument()
const { mutate: updateDocumentMutation, mutateAsync: updateDocumentAsync } = useUpdateDocument()
const { mutate: deleteDocumentMutation } = useDeleteDocument()
const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } =
useDeleteKnowledgeBase(workspaceId)
@@ -285,7 +285,7 @@ export function KnowledgeBase({
} = useKnowledgeBase(id)
const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id)
const hasSyncingConnectors = connectors.some((c) => c.status === 'syncing')
const hasSyncingConnectors = connectors.some(isConnectorSyncingOrPending)
const hasSyncingConnectorsRef = useRef(hasSyncingConnectors)
hasSyncingConnectorsRef.current = hasSyncingConnectors
@@ -455,28 +455,16 @@ export function KnowledgeBase({
updateDocument(documentId, { filename: newName })
return new Promise<void>((resolve, reject) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId,
updates: { filename: newName },
},
{
onSuccess: () => {
logger.info(`Document renamed: ${documentId}`)
resolve()
},
onError: (err) => {
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
logger.error('Error renaming document:', err)
reject(err)
},
}
)
})
try {
await updateDocumentAsync({ knowledgeBaseId: id, documentId, updates: { filename: newName } })
logger.info(`Document renamed: ${documentId}`)
} catch (err) {
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
logger.error('Error renaming document:', err)
throw err
}
}
/**

View File

@@ -195,9 +195,6 @@ export function AddDocumentsModal({
try {
await uploadFiles([fileToRetry], knowledgeBaseId, {
chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 1,
chunkOverlap: chunkingConfig?.overlap || 200,
recipe: 'default',
})
removeFile(index)
@@ -217,9 +214,6 @@ export function AddDocumentsModal({
try {
await uploadFiles(files, knowledgeBaseId, {
chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 1,
chunkOverlap: chunkingConfig?.overlap || 200,
recipe: 'default',
})
logger.info(`Successfully uploaded ${files.length} files`)

View File

@@ -20,6 +20,7 @@ interface BaseCardProps {
createdAt?: string
updatedAt?: string
connectorTypes?: string[]
chunkingConfig?: { maxSize: number; minSize: number; overlap: number }
onUpdate?: (id: string, name: string, description: string) => Promise<void>
onDelete?: (id: string) => Promise<void>
}
@@ -78,6 +79,7 @@ export function BaseCard({
description,
updatedAt,
connectorTypes = [],
chunkingConfig,
onUpdate,
onDelete,
}: BaseCardProps) {
@@ -256,6 +258,7 @@ export function BaseCard({
knowledgeBaseId={id}
initialName={title}
initialDescription={description === 'No description provided' ? '' : description}
chunkingConfig={chunkingConfig}
onSave={handleSave}
/>
)}

View File

@@ -269,9 +269,6 @@ export const CreateBaseModal = memo(function CreateBaseModal({
if (files.length > 0) {
try {
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
chunkSize: data.maxChunkSize,
minCharactersPerChunk: data.minChunkSize,
chunkOverlap: data.overlapSize,
recipe: 'default',
})
@@ -358,12 +355,15 @@ export const CreateBaseModal = memo(function CreateBaseModal({
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
type='number'
min={1}
max={2000}
step={1}
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
</div>
@@ -371,12 +371,15 @@ export const CreateBaseModal = memo(function CreateBaseModal({
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Input
id='maxChunkSize'
type='number'
min={100}
max={4000}
step={1}
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
/>
</div>
</div>
@@ -385,12 +388,15 @@ export const CreateBaseModal = memo(function CreateBaseModal({
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Input
id='overlapSize'
type='number'
min={0}
max={500}
step={1}
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='overlap-size'
/>
<p className='text-[var(--text-muted)] text-xs'>
1 token 4 characters. Max chunk size and overlap are in tokens.

View File

@@ -17,6 +17,7 @@ import {
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ChunkingConfig } from '@/lib/knowledge/types'
const logger = createLogger('EditKnowledgeBaseModal')
@@ -26,6 +27,7 @@ interface EditKnowledgeBaseModalProps {
knowledgeBaseId: string
initialName: string
initialDescription: string
chunkingConfig?: ChunkingConfig
onSave: (id: string, name: string, description: string) => Promise<void>
}
@@ -49,6 +51,7 @@ export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
knowledgeBaseId,
initialName,
initialDescription,
chunkingConfig,
onSave,
}: EditKnowledgeBaseModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -137,6 +140,47 @@ export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
</p>
)}
</div>
{chunkingConfig && (
<div className='flex flex-col gap-2'>
<Label>Chunking Configuration</Label>
<div className='grid grid-cols-3 gap-2'>
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>
Max Size
</p>
<p className='font-medium text-[var(--text-primary)] text-sm'>
{chunkingConfig.maxSize.toLocaleString()}
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
tokens
</span>
</p>
</div>
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>
Min Size
</p>
<p className='font-medium text-[var(--text-primary)] text-sm'>
{chunkingConfig.minSize.toLocaleString()}
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
chars
</span>
</p>
</div>
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2'>
<p className='text-[11px] text-[var(--text-tertiary)] leading-tight'>
Overlap
</p>
<p className='font-medium text-[var(--text-primary)] text-sm'>
{chunkingConfig.overlap.toLocaleString()}
<span className='ml-0.5 font-normal text-[11px] text-[var(--text-tertiary)]'>
tokens
</span>
</p>
</div>
</div>
</div>
)}
</div>
</ModalBody>

View File

@@ -46,9 +46,6 @@ export interface UploadError {
}
export interface ProcessingOptions {
chunkSize?: number
minCharactersPerChunk?: number
chunkOverlap?: number
recipe?: string
}
@@ -1011,10 +1008,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
...file,
})),
processingOptions: {
chunkSize: processingOptions.chunkSize || 1024,
minCharactersPerChunk: processingOptions.minCharactersPerChunk || 1,
chunkOverlap: processingOptions.chunkOverlap || 200,
recipe: processingOptions.recipe || 'default',
recipe: processingOptions.recipe ?? 'default',
lang: 'en',
},
bulk: true,

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import type { ComboboxOption } from '@/components/emcn'
@@ -33,6 +33,7 @@ import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Knowledge')
@@ -91,6 +92,13 @@ export function Knowledge() {
const router = useRouter()
const workspaceId = params.workspaceId as string
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (permissionConfig.hideKnowledgeBaseTab) {
router.replace(`/workspace/${workspaceId}`)
}
}, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId])
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
@@ -594,6 +602,7 @@ export function Knowledge() {
knowledgeBaseId={activeKnowledgeBase.id}
initialName={activeKnowledgeBase.name}
initialDescription={activeKnowledgeBase.description || ''}
chunkingConfig={activeKnowledgeBase.chunkingConfig}
onSave={handleUpdateKnowledgeBase}
/>
)}

View File

@@ -1,7 +1,7 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_ROW_COUNT = 5
const COLUMN_COUNT = 6
const COLUMN_COUNT = 7
export default function KnowledgeLoading() {
return (

View File

@@ -1,37 +1,8 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Knowledge } from './knowledge'
export const metadata: Metadata = {
title: 'Knowledge Base',
}
interface KnowledgePageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function KnowledgePage({ params }: KnowledgePageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Knowledge />
}
export default Knowledge

View File

@@ -5,6 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
@@ -16,6 +17,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />

View File

@@ -1,10 +1,11 @@
import { memo } from 'react'
import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { StatusBar, type StatusBarSegment } from '..'
export interface WorkflowExecutionItem {
@@ -36,7 +37,8 @@ function WorkflowsListInner({
searchQuery: string
segmentDurationMs: number
}) {
const workflows = useWorkflowRegistry((s) => s.workflows)
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflows = {} } = useWorkflowMap(workspaceId)
return (
<div className='flex h-full flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-1)]'>

View File

@@ -2,12 +2,13 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { Skeleton } from '@/components/emcn'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useFilterStore } from '@/stores/logs/filters/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { LineChart, WorkflowsList } from './components'
interface WorkflowExecution {
@@ -156,7 +157,8 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
}))
)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId)
const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null
@@ -459,7 +461,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
)
}
if (Object.keys(allWorkflows).length === 0) {
if (!isWorkflowsPending && allWorkflowList.length === 0) {
return (
<div className='mt-6 flex flex-1 items-center justify-center'>
<div className='text-center text-[var(--text-secondary)]'>

View File

@@ -25,9 +25,7 @@ export function WorkflowSelector({
onChange,
error,
}: WorkflowSelectorProps) {
const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, {
syncRegistry: false,
})
const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId)
const options: ComboboxOption[] = useMemo(() => {
return workflows.map((w) => ({

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
@@ -14,8 +15,8 @@ import {
type WorkflowData,
} from '@/lib/logs/search-suggestions'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
function truncateFilterValue(field: string, value: string): string {
if ((field === 'executionId' || field === 'workflowId') && value.length > 12) {
@@ -42,16 +43,17 @@ export function AutocompleteSearch({
className,
onOpenChange,
}: AutocompleteSearchProps) {
const workflows = useWorkflowRegistry((state) => state.workflows)
const folders = useFolderStore((state) => state.folders)
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: workflowList = [] } = useWorkflows(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const workflowsData = useMemo<WorkflowData[]>(() => {
return Object.values(workflows).map((w) => ({
return workflowList.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
}))
}, [workflows])
}, [workflowList])
const foldersData = useMemo<FolderData[]>(() => {
return Object.values(folders).map((f) => ({
@@ -103,6 +105,7 @@ export function AutocompleteSearch({
} = useSearchState({
onFiltersChange: handleFiltersChange,
getSuggestions: (input) => suggestionEngine.getSuggestions(input),
initialQuery: value,
})
const lastExternalValue = useRef(value)
@@ -114,14 +117,6 @@ export function AutocompleteSearch({
}
}, [value, initializeFromQuery])
useEffect(() => {
if (value) {
const parsed = parseQuery(value)
initializeFromQuery(parsed.textSearch, parsed.filters)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [dropdownWidth, setDropdownWidth] = useState(400)
useEffect(() => {
const measure = () => {

View File

@@ -20,10 +20,10 @@ import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useFilterStore } from '@/stores/logs/filters/store'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { AutocompleteSearch } from './components/search'
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
@@ -218,17 +218,17 @@ export const LogsToolbar = memo(function LogsToolbar({
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
const folders = useFolderStore((state) => state.folders)
const { data: folders = {} } = useFolderMap(workspaceId)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const { data: allWorkflowList = [] } = useWorkflows(workspaceId)
const workflows = useMemo(() => {
return Object.values(allWorkflows).map((w) => ({
return allWorkflowList.map((w) => ({
id: w.id,
name: w.name,
color: w.color,
}))
}, [allWorkflows])
}, [allWorkflowList])
const folderList = useMemo(() => {
return Object.values(folders).filter((f) => f.workspaceId === workspaceId)

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import type { ParsedFilter } from '@/lib/logs/query-parser'
import { parseQuery } from '@/lib/logs/query-parser'
import type {
Suggestion,
SuggestionGroup,
@@ -10,16 +11,21 @@ interface UseSearchStateOptions {
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
getSuggestions: (input: string) => SuggestionGroup | null
debounceMs?: number
initialQuery?: string
}
export function useSearchState({
onFiltersChange,
getSuggestions,
debounceMs = 100,
initialQuery,
}: UseSearchStateOptions) {
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>([])
const [initialParsed] = useState(() =>
initialQuery ? parseQuery(initialQuery) : { filters: [] as ParsedFilter[], textSearch: '' }
)
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>(initialParsed.filters)
const [currentInput, setCurrentInput] = useState('')
const [textSearch, setTextSearch] = useState('')
const [textSearch, setTextSearch] = useState<string>(initialParsed.textSearch)
const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
@@ -84,7 +90,7 @@ export function useSearchState({
}
const newFilter: ParsedFilter = {
field: suggestion.value.split(':')[0] as any,
field: suggestion.value.split(':')[0],
operator: '=',
value: suggestion.value.includes(':')
? suggestion.value.split(':').slice(1).join(':').replace(/"/g, '')

View File

@@ -50,19 +50,18 @@ import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-sea
import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getBlock } from '@/blocks/registry'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import {
prefetchLogDetail,
useDashboardStats,
useLogDetail,
useLogsList,
} from '@/hooks/queries/logs'
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
import { useDebounce } from '@/hooks/use-debounce'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import {
Dashboard,
ExecutionSnapshot,
@@ -266,19 +265,20 @@ export default function Logs() {
isSidebarOpen: false,
})
const isInitialized = useRef<boolean>(false)
const pendingExecutionIdRef = useRef<string | null>(null)
const pendingExecutionIdRef = useRef<string | null | undefined>(undefined)
if (pendingExecutionIdRef.current === undefined) {
pendingExecutionIdRef.current =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('executionId')
: null
}
const [searchQuery, setSearchQuery] = useState('')
const [searchQuery, setSearchQuery] = useState(() => {
if (typeof window === 'undefined') return ''
return new URLSearchParams(window.location.search).get('search') ?? ''
})
const debouncedSearchQuery = useDebounce(searchQuery, 300)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlSearch = params.get('search')
if (urlSearch) setSearchQuery(urlSearch)
const urlExecutionId = params.get('executionId')
if (urlExecutionId) pendingExecutionIdRef.current = urlExecutionId
}, [])
const isLive = true
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
@@ -783,8 +783,8 @@ export default function Logs() {
]
)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const folders = useFolderStore((state) => state.folders)
const { data: allWorkflows = {} } = useWorkflowMap(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const filterTags = useMemo<FilterTag[]>(() => {
const tags: FilterTag[] = []
@@ -1243,12 +1243,12 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
const folders = useFolderStore((state) => state.folders)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
const { data: folders = {} } = useFolderMap(workspaceId)
const { data: allWorkflowList = [] } = useWorkflows(workspaceId)
const workflows = useMemo(
() => Object.values(allWorkflows).map((w) => ({ id: w.id, name: w.name, color: w.color })),
[allWorkflows]
() => allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color })),
[allWorkflowList]
)
const folderList = useMemo(

View File

@@ -2,8 +2,10 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useProviderModels } from '@/hooks/queries/providers'
import {
updateFireworksProviderModels,
updateOllamaProviderModels,
updateOpenRouterProviderModels,
updateVLLMProviderModels,
@@ -12,11 +14,11 @@ import { type ProviderName, useProvidersStore } from '@/stores/providers'
const logger = createLogger('ProviderModelsLoader')
function useSyncProvider(provider: ProviderName) {
function useSyncProvider(provider: ProviderName, workspaceId?: string) {
const setProviderModels = useProvidersStore((state) => state.setProviderModels)
const setProviderLoading = useProvidersStore((state) => state.setProviderLoading)
const setOpenRouterModelInfo = useProvidersStore((state) => state.setOpenRouterModelInfo)
const { data, isLoading, isFetching, error } = useProviderModels(provider)
const { data, isLoading, isFetching, error } = useProviderModels(provider, workspaceId)
useEffect(() => {
setProviderLoading(provider, isLoading || isFetching)
@@ -35,6 +37,8 @@ function useSyncProvider(provider: ProviderName) {
if (data.modelInfo) {
setOpenRouterModelInfo(data.modelInfo)
}
} else if (provider === 'fireworks') {
void updateFireworksProviderModels(data.models)
}
} catch (syncError) {
logger.warn(`Failed to sync provider definitions for ${provider}`, syncError as Error)
@@ -51,9 +55,13 @@ function useSyncProvider(provider: ProviderName) {
}
export function ProviderModelsLoader() {
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
useSyncProvider('base')
useSyncProvider('ollama')
useSyncProvider('vllm')
useSyncProvider('openrouter')
useSyncProvider('fireworks', workspaceId)
return null
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Keeps workflow registry workspace scope synchronized with the current route.
*/
export function WorkspaceScopeSync() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const hydrationWorkspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId)
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
useEffect(() => {
if (!workspaceId || hydrationWorkspaceId === workspaceId) {
return
}
switchToWorkspace(workspaceId)
}, [hydrationWorkspaceId, switchToWorkspace, workspaceId])
return null
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -151,48 +151,45 @@ function buildCronExpression(
}
}
/**
* Modal for creating and editing scheduled tasks.
*
* All `useState` initializers read from the `schedule` prop at mount time only.
* When editing an existing task, the call-site **must** supply a `key` prop equal to the
* task's ID so React remounts the component when the selected task changes — otherwise
* the form will display stale values from the previously selected task.
*/
export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: ScheduleModalProps) {
const createScheduleMutation = useCreateSchedule()
const updateScheduleMutation = useUpdateSchedule()
const isEditing = Boolean(schedule)
const [title, setTitle] = useState('')
const [prompt, setPrompt] = useState('')
const [scheduleType, setScheduleType] = useState<ScheduleType>('daily')
const [minutesInterval, setMinutesInterval] = useState('15')
const [hourlyMinute, setHourlyMinute] = useState('0')
const [dailyTime, setDailyTime] = useState('09:00')
const [weeklyDay, setWeeklyDay] = useState('MON')
const [weeklyDayTime, setWeeklyDayTime] = useState('09:00')
const [monthlyDay, setMonthlyDay] = useState('1')
const [monthlyTime, setMonthlyTime] = useState('09:00')
const [cronExpression, setCronExpression] = useState('')
const [timezone, setTimezone] = useState(DEFAULT_TIMEZONE)
const [startDate, setStartDate] = useState('')
const [lifecycle, setLifecycle] = useState<'persistent' | 'until_complete'>('persistent')
const [maxRuns, setMaxRuns] = useState('')
const [submitError, setSubmitError] = useState<string | null>(null)
const initialCronState = useMemo(
() => (schedule ? parseCronToScheduleType(schedule.cronExpression) : null),
[schedule]
)
useEffect(() => {
if (!open || !schedule) return
const cronState = parseCronToScheduleType(schedule.cronExpression)
setTitle(schedule.jobTitle || '')
setPrompt(schedule.prompt || '')
setScheduleType(cronState.scheduleType)
setMinutesInterval(cronState.minutesInterval)
setHourlyMinute(cronState.hourlyMinute)
setDailyTime(cronState.dailyTime)
setWeeklyDay(cronState.weeklyDay)
setWeeklyDayTime(cronState.weeklyDayTime)
setMonthlyDay(cronState.monthlyDay)
setMonthlyTime(cronState.monthlyTime)
setCronExpression(cronState.cronExpression)
setTimezone(schedule.timezone || DEFAULT_TIMEZONE)
setLifecycle(schedule.lifecycle === 'until_complete' ? 'until_complete' : 'persistent')
setMaxRuns(schedule.maxRuns ? String(schedule.maxRuns) : '')
setStartDate('')
}, [open, schedule])
const [title, setTitle] = useState(schedule?.jobTitle ?? '')
const [prompt, setPrompt] = useState(schedule?.prompt ?? '')
const [scheduleType, setScheduleType] = useState<ScheduleType>(
initialCronState?.scheduleType ?? 'daily'
)
const [minutesInterval, setMinutesInterval] = useState(initialCronState?.minutesInterval ?? '15')
const [hourlyMinute, setHourlyMinute] = useState(initialCronState?.hourlyMinute ?? '0')
const [dailyTime, setDailyTime] = useState(initialCronState?.dailyTime ?? '09:00')
const [weeklyDay, setWeeklyDay] = useState(initialCronState?.weeklyDay ?? 'MON')
const [weeklyDayTime, setWeeklyDayTime] = useState(initialCronState?.weeklyDayTime ?? '09:00')
const [monthlyDay, setMonthlyDay] = useState(initialCronState?.monthlyDay ?? '1')
const [monthlyTime, setMonthlyTime] = useState(initialCronState?.monthlyTime ?? '09:00')
const [cronExpression, setCronExpression] = useState(initialCronState?.cronExpression ?? '')
const [timezone, setTimezone] = useState(schedule?.timezone ?? DEFAULT_TIMEZONE)
const [startDate, setStartDate] = useState('')
const [lifecycle, setLifecycle] = useState<'persistent' | 'until_complete'>(
schedule?.lifecycle === 'until_complete' ? 'until_complete' : 'persistent'
)
const [maxRuns, setMaxRuns] = useState(schedule?.maxRuns ? String(schedule.maxRuns) : '')
const [submitError, setSubmitError] = useState<string | null>(null)
const computedCron = useMemo(
() =>

View File

@@ -1,31 +1,8 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { ScheduledTasks } from './scheduled-tasks'
export const metadata: Metadata = {
title: 'Scheduled Tasks',
}
interface ScheduledTasksPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function ScheduledTasksPage({ params }: ScheduledTasksPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
return <ScheduledTasks />
}
export default ScheduledTasks

View File

@@ -433,6 +433,7 @@ export function ScheduledTasks() {
/>
<ScheduleModal
key={activeTask?.id ?? 'new'}
open={isEditModalOpen}
onOpenChange={(open) => {
setIsEditModalOpen(open)

View File

@@ -18,6 +18,7 @@ import {
BrandfetchIcon,
ExaAIIcon,
FirecrawlIcon,
FireworksIcon,
GeminiIcon,
GoogleIcon,
JinaAIIcon,
@@ -75,6 +76,13 @@ const PROVIDERS: {
description: 'LLM calls and Knowledge Base OCR',
placeholder: 'Enter your API key',
},
{
id: 'fireworks',
name: 'Fireworks',
icon: FireworksIcon,
description: 'LLM calls',
placeholder: 'Enter your Fireworks API key',
},
{
id: 'firecrawl',
name: 'Firecrawl',

View File

@@ -119,7 +119,7 @@ export function RecentlyDeleted() {
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' })
const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' })
const tablesQuery = useTablesList(workspaceId, 'archived')
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
@@ -245,7 +245,10 @@ export function RecentlyDeleted() {
switch (resource.type) {
case 'workflow':
restoreWorkflow.mutate(resource.id, { onSettled, onSuccess })
restoreWorkflow.mutate(
{ workflowId: resource.id, workspaceId: resource.workspaceId },
{ onSettled, onSuccess }
)
break
case 'table':
restoreTable.mutate(resource.id, { onSettled, onSuccess })

View File

@@ -27,9 +27,6 @@ export default function TablesLoading() {
<table className='w-full'>
<thead>
<tr className='border-[var(--border)] border-b'>
<th className='w-[40px] px-[12px] py-[8px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</th>
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
<th key={i} className='px-[12px] py-[8px] text-left'>
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
@@ -40,9 +37,6 @@ export default function TablesLoading() {
<tbody>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
<tr key={rowIndex} className='border-[var(--border)] border-b'>
<td className='w-[40px] px-[12px] py-[10px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</td>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<td key={colIndex} className='px-[12px] py-[10px]'>
<Skeleton

View File

@@ -1,37 +1,8 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Tables } from './tables'
export const metadata: Metadata = {
title: 'Tables',
}
interface TablesPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function TablesPage({ params }: TablesPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideTablesTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Tables />
}
export default Tables

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import type { ComboboxOption } from '@/components/emcn'
@@ -38,6 +38,7 @@ import {
} from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Tables')
@@ -54,6 +55,14 @@ export function Tables() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (permissionConfig.hideTablesTab) {
router.replace(`/workspace/${workspaceId}`)
}
}, [permissionConfig.hideTablesTab, router, workspaceId])
const userPermissions = useUserPermissionsContext()
const { data: tables = [], isLoading, error } = useTablesList(workspaceId)

View File

@@ -1143,13 +1143,15 @@ export function Chat() {
{isStreaming ? (
<Button
onClick={handleStopStreaming}
className='h-[22px] w-[22px] rounded-full border-0 bg-[var(--text-primary)] p-0 transition-colors hover-hover:bg-[var(--text-secondary)] dark:bg-[var(--border-1)] dark:hover-hover:bg-[var(--text-body)]'
variant='ghost'
className='h-[22px] w-[22px] rounded-full bg-[#383838] p-0 transition-colors hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]'
>
<Square className='h-2.5 w-2.5 fill-white text-white dark:fill-black dark:text-black' />
</Button>
) : (
<Button
onClick={handleSendMessage}
variant='ghost'
disabled={
(!chatMessage.trim() && chatFiles.length === 0) ||
!activeWorkflowId ||
@@ -1157,10 +1159,10 @@ export function Chat() {
isStreaming
}
className={cn(
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
'h-[22px] w-[22px] rounded-full p-0 transition-colors',
chatMessage.trim() || chatFiles.length > 0
? 'bg-[var(--text-primary)] hover-hover:bg-[var(--text-secondary)] dark:bg-[var(--border-1)] dark:hover-hover:bg-[var(--text-body)]'
: 'bg-[var(--text-subtle)] dark:bg-[var(--text-subtle)]'
? 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]'
: 'bg-[#808080] dark:bg-[#808080]'
)}
>
<ArrowUp

View File

@@ -70,7 +70,6 @@ export const FOLDER_CONFIGS: Record<MentionFolderId, FolderConfig> = {
title: 'All workflows',
dataKey: 'workflows',
loadingKey: 'isLoadingWorkflows',
// No ensureLoadedKey - workflows auto-load from registry store
getLabel: (item) => item.name || 'Untitled Workflow',
getId: (item) => item.id,
emptyMessage: 'No workflows',

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useWorkflows } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -151,14 +152,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
useShallow(useCallback((state) => Object.keys(state.blocks), []))
)
const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows))
const { data: registryWorkflowList = [] } = useWorkflows(workspaceId)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isLoadingWorkflows =
hydrationPhase === 'idle' ||
hydrationPhase === 'metadata-loading' ||
hydrationPhase === 'state-loading'
const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
const workflows: WorkflowItem[] = Object.values(registryWorkflows)
const workflows: WorkflowItem[] = registryWorkflowList
.filter((w) => w.workspaceId === workspaceId)
.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0

View File

@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
@@ -19,6 +20,7 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments'
import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -33,16 +35,16 @@ interface ApiInfoModalProps {
}
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const blocks = useWorkflowStore((state) => state.blocks)
const setValue = useSubBlockStore((state) => state.setValue)
const subBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const workflowMetadata = workflowId ? workflows[workflowId] : undefined
const updateWorkflowMutation = useUpdateWorkflow()
const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open })
const updatePublicApiMutation = useUpdatePublicApi()
@@ -175,7 +177,11 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
if (description.trim() !== (workflowMetadata?.description || '')) {
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId,
metadata: { description: description.trim() || 'New workflow' },
})
}
if (starterBlockId) {
@@ -195,16 +201,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
}, [
workflowId,
workspaceId,
description,
workflowMetadata,
updateWorkflow,
starterBlockId,
inputFormat,
paramDescriptions,
setValue,
onOpenChange,
accessMode,
updatePublicApiMutation,
])
return (

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
@@ -35,6 +36,7 @@ import {
} from '@/hooks/queries/deployments'
// import { useTemplateByWorkflow } from '@/hooks/queries/templates'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -85,14 +87,15 @@ export function DeployModal({
isLoadingDeployedState,
}: DeployModalProps) {
const queryClient = useQueryClient()
const params = useParams()
const workspaceId = params?.workspaceId as string
const { navigateToSettings } = useSettingsNavigation()
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
const [activeTab, setActiveTab] = useState<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)

View File

@@ -22,10 +22,7 @@ interface DeployProps {
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isRegistryLoading =
hydrationPhase === 'idle' ||
hydrationPhase === 'metadata-loading' ||
hydrationPhase === 'state-loading'
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
const { hasBlocks } = useCurrentWorkflow()
const deploymentStatus = useWorkflowRegistry((state) =>

View File

@@ -196,7 +196,7 @@ export function KnowledgeTagFilters({
if (isReadOnly) return
const updatedFilters = filters.map((f) => (f.id === id ? { ...f, [field]: value } : f))
const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null
const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : ''
emitTagSelection(jsonValue)
}

View File

@@ -1103,11 +1103,9 @@ try {
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{param.name}
</span>
<span className='flex-1 truncate'>{param.name}</span>
{param.type && param.type !== 'any' && (
<span className='ml-auto text-[var(--text-secondary)] text-micro'>
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
{param.type}
</span>
)}

View File

@@ -19,6 +19,7 @@ import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflo
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -78,9 +79,10 @@ export function ToolCredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
activeWorkflowId && workflowMap[activeWorkflowId] ? activeWorkflowId : undefined
const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`

View File

@@ -500,7 +500,7 @@ export const ToolInput = memo(function ToolInput({
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const mcpDataLoading = mcpLoading || mcpServersLoading
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const { data: workflowsList = [] } = useWorkflows(workspaceId)
const availableWorkflows = useMemo(
() => workflowsList.filter((w) => w.id !== workflowId),
[workflowsList, workflowId]

View File

@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import type { SubBlockConfig } from '@/blocks/types'
@@ -22,13 +23,15 @@ export function WorkflowSelectorInput({
isPreview = false,
previewValue,
}: WorkflowSelectorInputProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const context: SelectorContext = useMemo(
() => ({
workspaceId,
excludeWorkflowId: activeWorkflowId ?? undefined,
}),
[activeWorkflowId]
[activeWorkflowId, workspaceId]
)
return (

View File

@@ -56,6 +56,7 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]
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 { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -126,18 +127,15 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const userPermissions = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry(
const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { activeWorkflowId, hydration } = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
activeWorkflowId: state.activeWorkflowId,
duplicateWorkflow: state.duplicateWorkflow,
hydration: state.hydration,
}))
)
const isRegistryLoading =
hydration.phase === 'idle' ||
hydration.phase === 'metadata-loading' ||
hydration.phase === 'state-loading'
const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading'
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
@@ -478,7 +476,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsExporting(true)
try {
const workflow = getWorkflowWithValues(activeWorkflowId)
const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId)
if (!workflow || !workflow.state) {
throw new Error('No workflow state found')
@@ -519,11 +517,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
return
}
const sourceWorkflow = workflows[activeWorkflowId]
if (!sourceWorkflow) return
setIsDuplicating(true)
try {
const newWorkflow = await duplicateWorkflow(activeWorkflowId)
if (newWorkflow) {
router.push(`/workspace/${workspaceId}/w/${newWorkflow}`)
const result = await duplicateWorkflowMutation.mutateAsync({
workspaceId,
sourceId: activeWorkflowId,
name: `${sourceWorkflow.name} (Copy)`,
description: sourceWorkflow.description,
color: sourceWorkflow.color ?? '',
folderId: sourceWorkflow.folderId,
})
if (result?.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
}
} catch (error) {
logger.error('Error duplicating workflow:', error)
@@ -531,14 +539,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsDuplicating(false)
setIsMenuOpen(false)
}
}, [
activeWorkflowId,
userPermissions.canEdit,
isDuplicating,
duplicateWorkflow,
router,
workspaceId,
])
}, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId])
/**
* Toggles the locked state of all blocks in the workflow

View File

@@ -46,9 +46,9 @@ import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials'
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
@@ -600,11 +600,11 @@ const SubBlockRow = memo(function SubBlockRow({
)
const knowledgeBaseDisplayName = kbForDisplayName?.name ?? null
const workflowMap = useWorkflowRegistry((state) => state.workflows)
const workflowSelectionName =
subBlock?.id === 'workflowId' && typeof rawValue === 'string'
? (workflowMap[rawValue]?.name ?? null)
: null
const { data: workflowMapForLookup = {} } = useWorkflowMap(workspaceId)
const workflowSelectionName = useMemo(() => {
if (subBlock?.id !== 'workflowId' || typeof rawValue !== 'string') return null
return workflowMapForLookup[rawValue]?.name ?? null
}, [workflowMapForLookup, subBlock?.id, rawValue])
const { data: mcpServers = [] } = useMcpServers(workspaceId || '')
const mcpServerDisplayName = useMemo(() => {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { v4 as uuidv4 } from 'uuid'
import { useShallow } from 'zustand/react/shallow'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -30,6 +31,7 @@ import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '
import { hasExecutionResult } from '@/executor/utils/errors'
import { coerceValue } from '@/executor/utils/start-block'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
@@ -102,11 +104,11 @@ function normalizeErrorMessage(error: unknown): string {
}
export function useWorkflowExecution() {
const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>()
const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId)
const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry(
useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows }))
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
useTerminalConsoleStore(
useShallow((s) => ({
@@ -382,13 +384,15 @@ export function useWorkflowExecution() {
// Sandbox exercises have no real workflow — signal the SandboxCanvasProvider
// to run mock execution by setting isExecuting, then bail out immediately.
if (workflows[activeWorkflowId]?.isSandbox) {
const scopedWorkspaceId = routeWorkspaceId ?? hydrationWorkspaceId ?? undefined
const cachedWorkflows = scopedWorkspaceId ? getWorkflows(scopedWorkspaceId) : []
const activeWorkflow = cachedWorkflows.find((w) => w.id === activeWorkflowId)
if (activeWorkflow?.isSandbox) {
setIsExecuting(activeWorkflowId, true)
return
}
// Get workspaceId from workflow metadata
const workspaceId = workflows[activeWorkflowId]?.workspaceId
const workspaceId = scopedWorkspaceId ?? activeWorkflow?.workspaceId
if (!workspaceId) {
logger.error('Cannot execute workflow without workspaceId')
@@ -748,7 +752,6 @@ export function useWorkflowExecution() {
setExecutor,
setPendingBlocks,
setActiveBlocks,
workflows,
queryClient,
]
)

View File

@@ -73,6 +73,7 @@ import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
@@ -279,7 +280,12 @@ const WorkflowContent = React.memo(
useOAuthReturnForWorkflow(workflowIdParam)
const {
workflows,
data: workflows = {},
isLoading: isWorkflowMapLoading,
isPlaceholderData: isWorkflowMapPlaceholderData,
} = useWorkflowMap(workspaceId)
const {
activeWorkflowId,
hydration,
setActiveWorkflow,
@@ -292,7 +298,6 @@ const WorkflowContent = React.memo(
clearPendingSelection,
} = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
activeWorkflowId: state.activeWorkflowId,
hydration: state.hydration,
setActiveWorkflow: state.setActiveWorkflow,
@@ -357,12 +362,14 @@ const WorkflowContent = React.memo(
const isWorkflowReady = useMemo(
() =>
!isWorkflowMapPlaceholderData &&
hydration.phase === 'ready' &&
hydration.workflowId === workflowIdParam &&
activeWorkflowId === workflowIdParam &&
Boolean(workflows[workflowIdParam]) &&
lastSaved !== undefined,
[
isWorkflowMapPlaceholderData,
hydration.phase,
hydration.workflowId,
workflowIdParam,
@@ -2204,23 +2211,22 @@ const WorkflowContent = React.memo(
)
const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
const currentWorkflowExists =
!isWorkflowMapPlaceholderData && Boolean(workflows[workflowIdParam])
useEffect(() => {
// In sandbox mode the stores are pre-hydrated externally; skip the API load.
if (sandbox) return
const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId
const isRegistryReady = hydration.phase !== 'metadata-loading' && hydration.phase !== 'idle'
// Wait for registry to be ready to prevent race conditions
// Wait for workflow data to be available before attempting to load
if (
isWorkflowMapLoading ||
isWorkflowMapPlaceholderData ||
!currentId ||
!currentWorkflowExists ||
!isRegistryReady ||
(currentWorkspaceHydration && currentWorkspaceHydration !== workspaceId)
!hydration.workspaceId ||
hydration.workspaceId !== workspaceId
) {
return
}
@@ -2269,6 +2275,8 @@ const WorkflowContent = React.memo(
}
}, [
workflowIdParam,
isWorkflowMapLoading,
isWorkflowMapPlaceholderData,
currentWorkflowExists,
activeWorkflowId,
setActiveWorkflow,
@@ -2286,8 +2294,12 @@ const WorkflowContent = React.memo(
useEffect(() => {
if (embedded || sandbox) return
// Wait for metadata to finish loading before making navigation decisions
if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') {
if (
isWorkflowMapLoading ||
isWorkflowMapPlaceholderData ||
!hydration.workspaceId ||
hydration.workspaceId !== workspaceId
) {
return
}
@@ -2330,9 +2342,12 @@ const WorkflowContent = React.memo(
}, [
embedded,
workflowIdParam,
isWorkflowMapLoading,
isWorkflowMapPlaceholderData,
currentWorkflowExists,
workflowCount,
hydration.phase,
hydration.workspaceId,
workspaceId,
router,
workflows,

View File

@@ -14,7 +14,7 @@ import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/** Execution status for blocks in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
@@ -48,6 +48,8 @@ const ERROR_HANDLE_STYLE: CSSProperties = {
interface WorkflowPreviewBlockData {
type: string
name: string
workflowMap?: Record<string, WorkflowMetadata>
workflowLabelsReady?: boolean
isTrigger?: boolean
horizontalHandles?: boolean
enabled?: boolean
@@ -77,6 +79,8 @@ interface SubBlockRowProps {
value?: string
subBlock?: SubBlockConfig
rawValue?: unknown
workflowMap: Record<string, WorkflowMetadata>
workflowLabelsReady: boolean
}
/**
@@ -107,12 +111,14 @@ function resolveDropdownLabel(
*/
function resolveWorkflowName(
subBlock: SubBlockConfig | undefined,
rawValue: unknown
rawValue: unknown,
workflowMap: Record<string, WorkflowMetadata>,
workflowLabelsReady: boolean
): string | null {
if (subBlock?.type !== 'workflow-selector') return null
if (!rawValue || typeof rawValue !== 'string') return null
if (!workflowLabelsReady) return null
const workflowMap = useWorkflowRegistry.getState().workflows
return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL
}
@@ -228,6 +234,8 @@ const SubBlockRow = memo(function SubBlockRow({
value,
subBlock,
rawValue,
workflowMap,
workflowLabelsReady,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -235,7 +243,7 @@ const SubBlockRow = memo(function SubBlockRow({
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue, workflowMap, workflowLabelsReady)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
@@ -272,6 +280,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const {
type,
name,
workflowMap = {},
workflowLabelsReady = false,
isTrigger = false,
horizontalHandles = false,
enabled = true,
@@ -492,6 +502,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
key={cond.id}
title={cond.title}
value={lightweight ? undefined : getDisplayValue(cond.value)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
))
) : type === 'router_v2' ? (
@@ -500,12 +512,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
key='context'
title='Context'
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={lightweight ? undefined : getDisplayValue(route.value)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
))}
</>
@@ -519,12 +535,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)
})
)}
{/* Error row for non-trigger blocks */}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
{shouldShowDefaultHandles && (
<SubBlockRow
title='error'
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)}
</div>
)}

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { useParams } from 'next/navigation'
import ReactFlow, {
ConnectionLineType,
type Edge,
@@ -19,6 +20,7 @@ import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('PreviewWorkflow')
@@ -130,6 +132,7 @@ function calculateAbsolutePosition(
interface PreviewWorkflowProps {
workflowState: WorkflowState
workspaceId?: string
className?: string
height?: string | number
width?: string | number
@@ -213,6 +216,7 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP
/** Readonly workflow visualization with execution status highlighting. */
export function PreviewWorkflow({
workflowState,
workspaceId: propWorkspaceId,
className,
height = '100%',
width = '100%',
@@ -228,6 +232,14 @@ export function PreviewWorkflow({
selectedBlockId,
lightweight = false,
}: PreviewWorkflowProps) {
const params = useParams<{ workspaceId: string }>()
const workspaceId = propWorkspaceId ?? params.workspaceId
const {
data: workflowMap = {},
isLoading: isWorkflowMapLoading,
isPlaceholderData: isWorkflowMapPlaceholderData,
} = useWorkflowMap(workspaceId)
const workflowLabelsReady = !isWorkflowMapLoading && !isWorkflowMapPlaceholderData
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
@@ -424,6 +436,8 @@ export function PreviewWorkflow({
data: {
type: block.type,
name: block.name,
workflowMap,
workflowLabelsReady,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
@@ -445,6 +459,8 @@ export function PreviewWorkflow({
executedBlocks,
selectedBlockId,
getSubflowExecutionStatus,
workflowMap,
workflowLabelsReady,
lightweight,
])

View File

@@ -27,10 +27,11 @@ import {
useExportSelection,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -245,16 +246,16 @@ export function FolderItem({
const workflowIds = Array.from(finalWorkflowSelection)
const isMixed = folderIds.length > 0 && workflowIds.length > 0
const { folders } = useFolderStore.getState()
const { workflows } = useWorkflowRegistry.getState()
const folderMap = getFolderMap(workspaceId)
const workflows = getWorkflows(workspaceId)
const names: string[] = []
for (const id of folderIds) {
const f = folders[id]
const f = folderMap[id]
if (f) names.push(f.name)
}
for (const id of workflowIds) {
const w = workflows[id]
const w = workflows.find((wf) => wf.id === id)
if (w) names.push(w.name)
}

View File

@@ -25,6 +25,9 @@ import {
useExportSelection,
useExportWorkflow,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -60,7 +63,7 @@ export function WorkflowItem({
const params = useParams()
const workspaceId = params.workspaceId as string
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const updateWorkflowMutation = useUpdateWorkflow()
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
@@ -166,9 +169,9 @@ export function WorkflowItem({
const handleColorChange = useCallback(
(color: string) => {
updateWorkflow(workflow.id, { color })
updateWorkflowMutation.mutate({ workspaceId, workflowId: workflow.id, metadata: { color } })
},
[workflow.id, updateWorkflow]
[workflow.id, workspaceId]
)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -227,16 +230,16 @@ export function WorkflowItem({
const folderIds = Array.from(finalFolderSelection)
const isMixed = workflowIds.length > 0 && folderIds.length > 0
const { workflows } = useWorkflowRegistry.getState()
const { folders } = useFolderStore.getState()
const workflows = getWorkflows(workspaceId)
const folderMap = getFolderMap(workspaceId)
const names: string[] = []
for (const id of workflowIds) {
const w = workflows[id]
const w = workflows.find((wf) => wf.id === id)
if (w) names.push(w.name)
}
for (const id of folderIds) {
const f = folders[id]
const f = folderMap[id]
if (f) names.push(f.name)
}
@@ -301,7 +304,11 @@ export function WorkflowItem({
} = useItemRename({
initialName: workflow.name,
onSave: async (newName) => {
await updateWorkflow(workflow.id, { name: newName })
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId: workflow.id,
metadata: { name: newName },
})
},
itemType: 'workflow',
itemId: workflow.id,
@@ -388,12 +395,13 @@ export function WorkflowItem({
data-item-id={workflow.id}
className={clsx(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
(active || isContextMenuOpen) && 'bg-[var(--surface-active)]',
(active || isContextMenuOpen || (isSelected && selectedWorkflows.size > 1)) &&
'bg-[var(--surface-active)]',
!active &&
!isContextMenuOpen &&
!(isSelected && selectedWorkflows.size > 1) &&
!isAnyDragActive &&
'hover-hover:bg-[var(--surface-hover)]',
isSelected && selectedWorkflows.size > 1 && !active && 'bg-[var(--surface-active)]',
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
draggable={!isEditing && !dragDisabled}

View File

@@ -3,6 +3,7 @@
import { memo, useCallback, useEffect, useMemo } from 'react'
import clsx from 'clsx'
import { useShallow } from 'zustand/react/shallow'
import { buildFolderTree, getFolderPath } from '@/lib/folders/tree'
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
@@ -18,7 +19,7 @@ import {
compareByOrder,
groupWorkflowsByFolder,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -78,12 +79,10 @@ export const WorkflowList = memo(function WorkflowList({
disableCreate = false,
}: WorkflowListProps) {
const { isLoading: foldersLoading } = useFolders(workspaceId)
const folders = useFolderStore((state) => state.folders)
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore(
const { data: folderMap = {} } = useFolderMap(workspaceId)
const { expandedFolders, setExpanded } = useFolderStore(
useShallow((s) => ({
getFolderTree: s.getFolderTree,
expandedFolders: s.expandedFolders,
getFolderPath: s.getFolderPath,
setExpanded: s.setExpanded,
}))
)
@@ -120,8 +119,8 @@ export const WorkflowList = memo(function WorkflowList({
}, [scrollContainerRef, setScrollContainer])
const folderTree = useMemo(
() => (workspaceId ? getFolderTree(workspaceId) : []),
[workspaceId, folders, getFolderTree]
() => (workspaceId ? buildFolderTree(folderMap, workspaceId) : []),
[workspaceId, folderMap]
)
const activeWorkflowFolderId = useMemo(() => {
@@ -354,7 +353,7 @@ export const WorkflowList = memo(function WorkflowList({
if (!workflowId || isLoading || foldersLoading) return
if (activeWorkflowFolderId) {
const folderPath = getFolderPath(activeWorkflowFolderId)
const folderPath = getFolderPath(folderMap, activeWorkflowFolderId)
folderPath.forEach((folder) => setExpanded(folder.id, true))
}
@@ -362,7 +361,7 @@ export const WorkflowList = memo(function WorkflowList({
if (!selectedWorkflows.has(workflowId)) {
selectOnly(workflowId)
}
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, folderMap, setExpanded])
const renderWorkflowItem = useCallback(
(workflow: WorkflowMetadata, level: number, folderId: string | null = null) => {

View File

@@ -120,6 +120,7 @@ export function WorkspaceHeader({
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [menuOpenWorkspaceId, setMenuOpenWorkspaceId] = useState<string | null>(null)
const contextMenuRef = useRef<HTMLDivElement | null>(null)
const capturedWorkspaceRef = useRef<Workspace | null>(null)
const isRenamingRef = useRef(false)
@@ -185,6 +186,7 @@ export function WorkspaceHeader({
contextMenuClosedRef.current = false
capturedWorkspaceRef.current = workspace
setMenuOpenWorkspaceId(workspace.id)
setContextMenuPosition({ x, y })
setIsContextMenuOpen(true)
}
@@ -210,6 +212,7 @@ export function WorkspaceHeader({
contextMenuClosedRef.current = true
setIsContextMenuOpen(false)
setMenuOpenWorkspaceId(null)
const isOpeningAnother = isContextMenuOpeningRef.current
isContextMenuOpeningRef.current = false
if (!isRenamingRef.current && !isOpeningAnother) {
@@ -494,8 +497,11 @@ export function WorkspaceHeader({
className={cn(
'group flex cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
workspace.id !== workspaceId &&
menuOpenWorkspaceId !== workspace.id &&
'hover-hover:bg-[var(--surface-hover)]',
workspace.id === workspaceId && 'bg-[var(--surface-active)]'
(workspace.id === workspaceId ||
menuOpenWorkspaceId === workspace.id) &&
'bg-[var(--surface-active)]'
)}
onClick={() => onWorkspaceSwitch(workspace)}
onContextMenu={(e) => handleContextMenu(e, workspace)}
@@ -513,7 +519,10 @@ export function WorkspaceHeader({
const rect = e.currentTarget.getBoundingClientRect()
openContextMenuAt(workspace, rect.right, rect.top)
}}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100'
className={cn(
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
menuOpenWorkspaceId === workspace.id && 'opacity-100'
)}
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>

View File

@@ -1,17 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getFolderPath } from '@/lib/folders/tree'
import { useReorderFolders } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useReorderWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowList:DragDrop')
const SCROLL_THRESHOLD = 60
const SCROLL_SPEED = 8
const HOVER_EXPAND_DELAY = 400
const DRAG_OVER_THROTTLE_MS = 16
export interface DropIndicator {
targetId: string
@@ -30,21 +31,35 @@ type SiblingItem = {
createdAt: Date
}
/** Root folder vs root workflow scope: API/cache may use null or undefined for "no parent". */
function isSameFolderScope(
parentOrFolderId: string | null | undefined,
scope: string | null
): boolean {
return (parentOrFolderId ?? null) === (scope ?? null)
}
export function useDragDrop(options: UseDragDropOptions = {}) {
const { disabled = false } = options
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
/**
* Mirrors `dropIndicator` synchronously. `drop` can fire before React commits the last
* `dragOver` state update, so `handleDrop` must read this ref instead of state.
*/
const dropIndicatorRef = useRef<DropIndicator | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const scrollAnimationRef = useRef<number | null>(null)
const hoverExpandTimerRef = useRef<number | null>(null)
const lastDragYRef = useRef<number>(0)
const lastDragOverTimeRef = useRef<number>(0)
const draggedSourceFolderRef = useRef<string | null>(null)
const siblingsCacheRef = useRef<Map<string, SiblingItem[]>>(new Map())
const isDraggingRef = useRef(false)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const reorderWorkflowsMutation = useReorderWorkflows()
const reorderFoldersMutation = useReorderFolders()
const setExpanded = useFolderStore((s) => s.setExpanded)
@@ -125,6 +140,10 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
}
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
useEffect(() => {
siblingsCacheRef.current.clear()
}, [workspaceId])
const calculateDropPosition = useCallback(
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
const rect = element.getBoundingClientRect()
@@ -162,12 +181,28 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
: indicator.folderId
}, [])
const calculateInsertIndex = useCallback(
(remaining: SiblingItem[], indicator: DropIndicator): number => {
return indicator.position === 'inside'
? remaining.length
: remaining.findIndex((item) => item.id === indicator.targetId) +
(indicator.position === 'after' ? 1 : 0)
/**
* Insert index into the list of siblings **excluding** moving items. Must use the full
* `siblingItems` list for lookup: when the drop line targets the dragged row,
* `indicator.targetId` is not present in `remaining`, so indexing `remaining` alone
* returns -1 and corrupts the splice.
*/
const getInsertIndexInRemaining = useCallback(
(siblingItems: SiblingItem[], movingIds: Set<string>, indicator: DropIndicator): number => {
if (indicator.position === 'inside') {
return siblingItems.filter((s) => !movingIds.has(s.id)).length
}
const targetIdx = siblingItems.findIndex((s) => s.id === indicator.targetId)
if (targetIdx === -1) {
return siblingItems.filter((s) => !movingIds.has(s.id)).length
}
if (indicator.position === 'before') {
return siblingItems.slice(0, targetIdx).filter((s) => !movingIds.has(s.id)).length
}
return siblingItems.slice(0, targetIdx + 1).filter((s) => !movingIds.has(s.id)).length
},
[]
)
@@ -215,57 +250,65 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
lastDragYRef.current = e.clientY
if (!isDragging) {
isDraggingRef.current = true
setIsDragging(true)
}
const now = performance.now()
if (now - lastDragOverTimeRef.current < DRAG_OVER_THROTTLE_MS) {
return false
}
lastDragOverTimeRef.current = now
return true
},
[isDragging]
)
const getSiblingItems = useCallback((folderId: string | null): SiblingItem[] => {
const cacheKey = folderId ?? 'root'
const cached = siblingsCacheRef.current.get(cacheKey)
if (cached) return cached
const getSiblingItems = useCallback(
(folderId: string | null): SiblingItem[] => {
const cacheKey = folderId ?? 'root'
if (!isDraggingRef.current) {
const cached = siblingsCacheRef.current.get(cacheKey)
if (cached) return cached
}
const currentFolders = useFolderStore.getState().folders
const currentWorkflows = useWorkflowRegistry.getState().workflows
const siblings = [
...Object.values(currentFolders)
.filter((f) => f.parentId === folderId)
.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
createdAt: f.createdAt,
})),
...Object.values(currentWorkflows)
.filter((w) => w.folderId === folderId)
.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
createdAt: w.createdAt,
})),
].sort(compareSiblingItems)
const currentFolders = workspaceId ? getFolderMap(workspaceId) : {}
const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : []
const siblings = [
...Object.values(currentFolders)
.filter((f) => isSameFolderScope(f.parentId, folderId))
.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
createdAt: f.createdAt,
})),
...currentWorkflows
.filter((w) => isSameFolderScope(w.folderId, folderId))
.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
createdAt: w.createdAt,
})),
].sort(compareSiblingItems)
siblingsCacheRef.current.set(cacheKey, siblings)
return siblings
}, [])
if (!isDraggingRef.current) {
siblingsCacheRef.current.set(cacheKey, siblings)
}
return siblings
},
[workspaceId]
)
const setNormalizedDropIndicator = useCallback(
(indicator: DropIndicator | null) => {
setDropIndicator((prev) => {
let next: DropIndicator | null = indicator
if (indicator === null) {
dropIndicatorRef.current = null
setDropIndicator(null)
return
}
if (indicator && indicator.position === 'after' && indicator.targetId !== 'root') {
const siblings = getSiblingItems(indicator.folderId)
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
let next: DropIndicator = indicator
if (indicator.position === 'after' && indicator.targetId !== 'root') {
const siblings = getSiblingItems(indicator.folderId)
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
if (currentIdx !== -1) {
const nextSibling = siblings[currentIdx + 1]
if (nextSibling) {
next = {
@@ -275,15 +318,18 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
}
}
}
}
setDropIndicator((prev) => {
if (
prev?.targetId === next?.targetId &&
prev?.position === next?.position &&
prev?.folderId === next?.folderId
prev?.targetId === next.targetId &&
prev?.position === next.position &&
prev?.folderId === next.folderId
) {
dropIndicatorRef.current = prev
return prev
}
dropIndicatorRef.current = next
return next
})
},
@@ -294,10 +340,11 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
(folderId: string, destinationFolderId: string | null): boolean => {
if (folderId === destinationFolderId) return false
if (!destinationFolderId) return true
const targetPath = useFolderStore.getState().getFolderPath(destinationFolderId)
if (!workspaceId) return false
const targetPath = getFolderPath(getFolderMap(workspaceId), destinationFolderId)
return !targetPath.some((f) => f.id === folderId)
},
[]
[workspaceId]
)
const collectMovingItems = useCallback(
@@ -306,14 +353,14 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
folderIds: string[],
destinationFolderId: string | null
): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => {
const { folders } = useFolderStore.getState()
const { workflows } = useWorkflowRegistry.getState()
const folders = workspaceId ? getFolderMap(workspaceId) : {}
const workflows = workspaceId ? getWorkflows(workspaceId) : []
const fromDestination: SiblingItem[] = []
const fromOther: SiblingItem[] = []
for (const id of workflowIds) {
const workflow = workflows[id]
const workflow = workflows.find((w) => w.id === id)
if (!workflow) continue
const item: SiblingItem = {
type: 'workflow',
@@ -321,7 +368,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
}
if (workflow.folderId === destinationFolderId) {
if (isSameFolderScope(workflow.folderId, destinationFolderId)) {
fromDestination.push(item)
} else {
fromOther.push(item)
@@ -337,7 +384,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
sortOrder: folder.sortOrder,
createdAt: folder.createdAt,
}
if (folder.parentId === destinationFolderId) {
if (isSameFolderScope(folder.parentId, destinationFolderId)) {
fromDestination.push(item)
} else {
fromOther.push(item)
@@ -349,7 +396,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
return { fromDestination, fromOther }
},
[]
[workspaceId]
)
const handleSelectionDrop = useCallback(
@@ -362,7 +409,9 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
try {
const destinationFolderId = getDestinationFolderId(indicator)
const validFolderIds = folderIds.filter((id) => canMoveFolderTo(id, destinationFolderId))
if (workflowIds.length === 0 && validFolderIds.length === 0) return
if (workflowIds.length === 0 && validFolderIds.length === 0) {
return
}
const siblingItems = getSiblingItems(destinationFolderId)
const movingIds = new Set([...workflowIds, ...validFolderIds])
@@ -374,7 +423,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
destinationFolderId
)
const insertAt = calculateInsertIndex(remaining, indicator)
const insertAt = getInsertIndexInRemaining(siblingItems, movingIds, indicator)
const newOrder = [
...remaining.slice(0, insertAt),
...fromDestination,
@@ -397,7 +446,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
canMoveFolderTo,
getSiblingItems,
collectMovingItems,
calculateInsertIndex,
getInsertIndexInRemaining,
buildAndSubmitUpdates,
]
)
@@ -407,8 +456,10 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
e.preventDefault()
e.stopPropagation()
const indicator = dropIndicator
const indicator = dropIndicatorRef.current
dropIndicatorRef.current = null
setDropIndicator(null)
isDraggingRef.current = false
setIsDragging(false)
siblingsCacheRef.current.clear()
@@ -427,7 +478,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
logger.error('Failed to handle drop:', error)
}
},
[dropIndicator, handleSelectionDrop]
[handleSelectionDrop]
)
const createWorkflowDragHandlers = useCallback(
@@ -535,7 +586,9 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e)) return
if (itemId) {
setDropIndicator({ targetId: itemId, position, folderId: null })
const edge: DropIndicator = { targetId: itemId, position, folderId: null }
dropIndicatorRef.current = edge
setDropIndicator(edge)
} else {
setNormalizedDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
}
@@ -548,11 +601,15 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
const handleDragStart = useCallback((sourceFolderId: string | null) => {
draggedSourceFolderRef.current = sourceFolderId
siblingsCacheRef.current.clear()
isDraggingRef.current = true
setIsDragging(true)
}, [])
const handleDragEnd = useCallback(() => {
isDraggingRef.current = false
setIsDragging(false)
dropIndicatorRef.current = null
setDropIndicator(null)
draggedSourceFolderRef.current = null
setHoverFolderId(null)

View File

@@ -1,11 +1,9 @@
import { useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import { useCreateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')
@@ -16,17 +14,14 @@ interface UseWorkflowOperationsProps {
export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) {
const router = useRouter()
const workflows = useWorkflowRegistry(useShallow((state) => state.workflows))
const workflowsQuery = useWorkflows(workspaceId)
const { data: workflows = {}, isLoading: workflowsLoading } = useWorkflowMap(workspaceId)
const createWorkflowMutation = useCreateWorkflow()
const regularWorkflows = useMemo(
() =>
Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}),
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[workflows, workspaceId]
)
@@ -59,7 +54,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
return {
workflows,
regularWorkflows,
workflowsLoading: workflowsQuery.isLoading,
workflowsLoading,
isCreatingWorkflow: createWorkflowMutation.isPending,
handleCreateWorkflow,

View File

@@ -110,7 +110,7 @@ export function useWorkspaceManagement({
}
try {
await switchToWorkspace(workspace.id)
switchToWorkspace(workspace.id)
routerRef.current?.push(`/workspace/${workspace.id}/home`)
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
} catch (error) {

View File

@@ -38,6 +38,7 @@ import {
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree } from '@/lib/folders/tree'
import {
START_NAV_TOUR_EVENT,
START_WORKFLOW_TOUR_EVENT,
@@ -77,7 +78,7 @@ import {
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getBrandConfig } from '@/ee/whitelabeling'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import {
@@ -88,6 +89,7 @@ import {
useRenameTask,
useTasks,
} from '@/hooks/queries/tasks'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -96,7 +98,6 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Sidebar')
@@ -203,7 +204,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
onMoreClick(e, task.id)
}}
className={cn(
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
isMenuOpen && 'opacity-100'
)}
>
@@ -436,13 +437,12 @@ export const Sidebar = memo(function Sidebar() {
})
useFolders(workspaceId)
const folders = useFolderStore((s) => s.folders)
const getFolderTree = useFolderStore((s) => s.getFolderTree)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { data: folderMap = {} } = useFolderMap(workspaceId)
const updateWorkflowMutation = useUpdateWorkflow()
const folderTree = useMemo(
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
[isCollapsed, workspaceId, folders, getFolderTree]
() => (isCollapsed && workspaceId ? buildFolderTree(folderMap, workspaceId) : []),
[isCollapsed, workspaceId, folderMap]
)
const workflowsByFolder = useMemo(
@@ -814,7 +814,11 @@ export const Sidebar = memo(function Sidebar() {
const workflowFlyoutRename = useFlyoutInlineRename({
itemType: 'workflow',
onSave: async (workflowIdToRename, name) => {
await updateWorkflow(workflowIdToRename, { name })
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId: workflowIdToRename,
metadata: { name },
})
},
})
@@ -1329,11 +1333,8 @@ export const Sidebar = memo(function Sidebar() {
!hasOverflowTop && 'border-transparent'
)}
>
<div
className='tasks-section mx-2 flex flex-shrink-0 flex-col'
data-tour='nav-tasks'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-2'>
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
@@ -1454,10 +1455,10 @@ export const Sidebar = memo(function Sidebar() {
</div>
<div
className='workflows-section relative mx-2 mt-3.5 flex flex-col'
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-2'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useFolderMap } from '@/hooks/queries/folders'
import { useWorkflows } from '@/hooks/queries/workflows'
interface UseCanDeleteProps {
/**
@@ -36,17 +36,15 @@ interface UseCanDeleteReturn {
* @returns Functions to check deletion eligibility
*/
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
const workflows = useWorkflowRegistry((s) => s.workflows)
const folders = useFolderStore((s) => s.folders)
const { data: workflowList = [] } = useWorkflows(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
/**
* Pre-computed data structures for efficient lookups
*/
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
useMemo(() => {
const workspaceWorkflows = Object.values(workflows).filter(
(w) => w.workspaceId === workspaceId
)
const workspaceWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId)
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
@@ -72,7 +70,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe
workflowsByFolderId: byFolderId,
childFoldersByParentId: childrenByParent,
}
}, [workflows, folders, workspaceId])
}, [workflowList, folders, workspaceId])
/**
* Count workflows in a folder and all its subfolders recursively.

View File

@@ -2,8 +2,8 @@ import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useDeleteFolderMutation } from '@/hooks/queries/folders'
import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useDeleteSelection')
@@ -46,8 +46,8 @@ export function useDeleteSelection({
onSuccess,
}: UseDeleteSelectionProps) {
const router = useRouter()
const workflows = useWorkflowRegistry((s) => s.workflows)
const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow)
const { data: workflowList = [] } = useWorkflows(workspaceId)
const deleteWorkflowMutation = useDeleteWorkflowMutation()
const deleteFolderMutation = useDeleteFolderMutation()
const [isDeleting, setIsDeleting] = useState(false)
@@ -72,7 +72,7 @@ export function useDeleteSelection({
? workflowIds.some((id) => isActiveWorkflow(id))
: false
const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId)
const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId)
const workflowsInFolders = sidebarWorkflows
.filter((w) => w.folderId && folderIds.includes(w.folderId))
@@ -128,7 +128,11 @@ export function useDeleteSelection({
}
const standaloneWorkflowIds = workflowIds.filter((id) => !workflowsInFolders.includes(id))
await Promise.all(standaloneWorkflowIds.map((id) => removeWorkflow(id)))
await Promise.all(
standaloneWorkflowIds.map((id) =>
deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id })
)
)
const { clearSelection, clearFolderSelection } = useFolderStore.getState()
clearSelection()
@@ -151,12 +155,10 @@ export function useDeleteSelection({
workflowIds,
folderIds,
isDeleting,
workflows,
workflowList,
workspaceId,
isActiveWorkflow,
router,
removeWorkflow,
deleteFolderMutation,
onSuccess,
])

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