Compare commits

...

63 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
Waleed
7d0fdefb22 v0.6.18: file operations block, profound integration, edge connection improvements, copy logs, knowledgebase robustness 2026-03-30 21:35:41 -07:00
Vikhyath Mondreti
90f592797a fix(file): use file-upload subblock (#3862)
* fix(file): use file-upload subblock

* fix preview + logs url for notifs

* fix color for profound

* remove canonical param from desc
2026-03-30 21:29:01 -07:00
Waleed
d091441e39 feat(logs): add copy link and deep-link support for log entries (#3863)
* feat(logs): add copy link and deep link support for log entries

* fix(logs): move Link icon to emcn and handle clipboard rejections

* feat(notifications): use executionId deep-link for View Log URLs

Switch buildLogUrl from ?search= to ?executionId= so email and Slack
'View Log' buttons open the logs page with the specific execution
auto-selected and the details panel expanded.
2026-03-30 21:17:58 -07:00
Waleed
7d4dd26760 fix(knowledge): fix document processing stuck in processing state (#3857)
* fix(knowledge): fix document processing stuck in processing state

* fix(knowledge): use Promise.allSettled for document dispatch and fix Copilot OAuth context

- Change Promise.all to Promise.allSettled in processDocumentsWithQueue so
  one failed dispatch doesn't abort the entire batch
- Add writeOAuthReturnContext before showing LazyOAuthRequiredModal from
  Copilot tools so useOAuthReturnForWorkflow can handle the return
- Add consumeOAuthReturnContext on modal close to clean up stale context

* fix(knowledge): fix type error in useCredentialRefreshTriggers call

Pass empty string instead of undefined for connectorProviderId fallback
to match the hook's string parameter type.

* upgrade turbo

* fix(knowledge): fix type error in connectors-section useCredentialRefreshTriggers call

Same string narrowing fix as add-connector-modal — pass empty string
fallback for providerId.
2026-03-30 20:35:08 -07:00
Vikhyath Mondreti
0abeac77e1 improvement(platform): standardize perms, audit logging, lifecycle across admin, copilot, ui actions (#3858)
* improvement(platform): standardize perms, audit logging, lifecycle mgmt across admin, copilot, ui actions

* address comments

* improve error codes

* address bugbot comments

* fix test
2026-03-30 20:25:38 -07:00
Waleed
e9c94fa462 feat(logs): add copy link and deep link support for log entries (#3855)
* feat(logs): add copy link and deep link support for log entries

* fix(logs): fetch next page when deep linked log is beyond initial page

* fix(logs): move Link icon to emcn and handle clipboard rejections

* fix(logs): track isFetching reactively and drop empty-list early-return

- Remove  guard that prevented clearing the
  pending ref when filters return no results
- Use  directly in the condition and add it to
  the effect deps so the effect re-triggers after a background refetch

* fix(logs): guard deep-link ref clear until query has succeeded

Only clear pendingExecutionIdRef when the query status is 'success',
preventing premature clearing before the initial fetch completes.
On mount, the query is disabled (isInitialized.current starts false),
so hasNextPage is false but no data has loaded yet — the ref was being
cleared in the same effect pass that set it.

* fix(logs): guard fetchNextPage call until query has succeeded

Add logsQuery.status === 'success' to the fetchNextPage branch so it
mirrors the clear branch. On mount the query is disabled (isFetching is
false, status is pending), causing the effect to call fetchNextPage()
before the query is initialized — now both branches require success.
2026-03-30 18:42:27 -07:00
Waleed
72eea64bf6 improvement(tour): align product tour tooltip styling with emcn and fix spotlight overflow (#3854) 2026-03-30 16:59:53 -07:00
Waleed
27460f847c fix(atlassian): harden cloud ID resolution for Confluence and Jira (#3853) 2026-03-30 16:54:45 -07:00
Vikhyath Mondreti
c7643198dc fix(mothership): hang condition (#3852) 2026-03-30 16:47:54 -07:00
Waleed
e5aef6184a feat(profound): add Profound AI visibility and analytics integration (#3849)
* feat(profound): add Profound AI visibility and analytics integration

* fix(profound): fix import ordering and JSON formatting for CI lint

* fix(profound): gate metrics mapping on current operation to prevent stale overrides

* fix(profound): guard JSON.parse on filters, fix offset=0 falsy check, remove duplicate prompt_answers in FILTER_OPS

* lint

* fix(docs): fix import ordering and trailing newline for docs lint

* fix(scripts): sort generated imports to match Biome's organizeImports order

* fix(profound): use != null checks for limit param across all tools

* fix(profound): flatten block output type to 'json' to pass block validation test

* fix(profound): remove invalid 'required' field from block inputs (not part of ParamConfig)

* fix(profound): rename tool files from kebab-case to snake_case for docs generator compatibility

* lint

* fix(docs): let biome auto-fix import order, revert custom sort in generator

* fix(landing): fix import order in sim icon-mapping via biome

* fix(scripts): match Biome's exact import sort order in docs generator

* fix(generate-docs): produce Biome-compatible JSON output

The generator wrote multi-line arrays for short string arrays (like tags)
and omitted trailing newlines, causing Biome format check failures in CI.
Post-process integrations.json to collapse short arrays onto single lines
and add trailing newlines to both integrations.json and meta.json.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:30:06 -07:00
Waleed
4ae5b1b620 improvement(workflow): use DOM hit-testing for edge drop-on-block detection (#3851) 2026-03-30 16:20:30 -07:00
Waleed
5c334874eb fix(auth): use standard 'Unauthorized' error in hybrid auth responses (#3850) 2026-03-30 16:11:04 -07:00
Theodore Li
d3d58a9615 Feat/improved logging (#3833)
* feat(logs): add additional metadata for workflow execution logs

* Revert "Feat(logs) upgrade mothership chat messages to error (#3772)"

This reverts commit 9d1b9763c5.

* Fix lint, address greptile comments

* improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830)

* improvement(sidebar): expand sidebar by hovering and clicking the edge

* improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA

* improvement(sidebar): use Tooltip.Shortcut for inline shortcut display

* fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict

* fix(hotkeys): fall back to event.code for international keyboard layout compatibility

* fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks

* feat(ui): handle image paste (#3826)

* feat(ui): handle image paste

* Fix lint

* Fix type error

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* feat(files): interactive markdown checkbox toggling in preview (#3829)

* feat(files): interactive markdown checkbox toggling in preview

* fix(files): handle ordered-list checkboxes and fix index drift

* lint

* fix(files): remove counter offset that prevented checkbox toggling

* fix(files): apply task-list styling to ordered lists too

* fix(files): render single pass when interactive to avoid index drift

* fix(files): move useMemo above conditional return to fix Rules of Hooks

* fix(files): pass content directly to preview when not streaming to avoid stale frame

* improvement(home): position @ mention popup at caret and fix icon consistency (#3831)

* improvement(home): position @ mention popup at caret and fix icon consistency

* fix(home): pin mirror div to document origin and guard button anchor

* chore(auth): restore hybrid.ts to staging

* improvement(ui): sidebar (#3832)

* Fix logger tests

* Add metadata to mothership logs

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-30 19:02:17 -04:00
Waleed
1d59eca90a fix(analytics): use getBaseDomain for Profound host field (#3848)
request.url resolves to internal ALB hostname on ECS, not the public domain
2026-03-30 15:36:41 -07:00
Theodore Li
e1359b09d6 feat(block) add block write and append operations (#3665)
* Add file write and delete operations

* Add file block write operation

* Fix lint

* Allow loop-in-loop workflow edits

* Fix type error

* Remove file id input, output link correctly

* Add append tool

* fix lint

* Address feedback

* Handle writing to same file name gracefully

* Removed  mime type from append block

* Add lock for file append operation

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-30 18:08:51 -04:00
Waleed
35b3646330 fix(sidebar): cmd+click opens in new tab, shift+click for range select (#3846)
* fix(sidebar): cmd+click opens in new tab, shift+click for range select

* comment cleanup

* fix(sidebar): drop stale metaKey param from workflow and task selection hooks
2026-03-30 11:02:33 -07:00
Waleed
73e00f53e1 v0.6.17: trigger.dev CI, workers FF 2026-03-30 09:33:30 -07:00
Waleed
5c47ea58f8 chore(trigger): update @trigger.dev/sdk and @trigger.dev/build to 4.4.3 (#3843)
* chore(trigger): update @trigger.dev/sdk and @trigger.dev/build to 4.4.3

* fix(webhooks): execute non-polling webhooks inline when BullMQ is disabled
2026-03-30 09:27:15 -07:00
Vikhyath Mondreti
1d7ae906bc v0.6.16: bullmq optionality 2026-03-30 00:12:21 -07:00
Vikhyath Mondreti
c4f4e6b48c fix(bullmq): disable temporarily (#3841) 2026-03-30 00:04:59 -07:00
Waleed
560fa75155 v0.6.15: workers, security hardening, sidebar improvements, chat fixes, profound 2026-03-29 23:02:19 -07:00
Waleed
1728c370de improvement(landing): lighthouse performance and accessibility fixes (#3837)
* improvement(landing): lighthouse performance and accessibility fixes

* improvement(landing): extract FeatureToggleItem to deduplicate accessibility logic

* lint

* fix(landing): ensure explicit delay prop takes precedence over transition spread
2026-03-29 22:33:34 -07:00
Waleed
82e58a5082 fix(academy): hide academy pages until content is ready (#3839)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:18:13 -07:00
Waleed
336c065234 fix(viewer): image pan/zoom, sort fixes, sidebar dot fixes (#3836)
* feat(file-viewer): add pan and zoom to image preview

* fix(viewer): fix sort key mapping, disable load-more on sort, hide status dots when menu open

* fix(file-viewer): prevent scroll bleed and zoom button micro-pans

* fix(file-viewer): use exponential zoom formula to prevent zero/negative multiplier
2026-03-29 12:40:29 -07:00
Waleed
b3713642b2 feat(resources): add sort and filter to all resource list pages (#3834)
* improvement(tables): improve table filtering UX

- Replace popover filter with persistent inline panel below toolbar
- Add AND/OR toggle between filter rules (shown in Where label slot)
- Sync filter panel state from applied filter on open
- Show filter button active state when filter is applied or panel is open
- Use readable operator labels matching dropdown options
- Add Clear filters button (shown only when filter is active)
- Close filter panel when last rule is removed via X
- Fix empty gap rows appearing in filtered results by skipping position gap rendering when filter is active
- Add toggle mode to ResourceOptionsBar for inline panel pattern
- Memoize FilterRuleRow for perf, fix filterTags key collision, remove dead filterActiveCount prop

* fix(table-filter): use ref to stabilize handleRemove/handleApply callbacks

Reading rules via ref instead of closure eliminates rules from useCallback
dependency arrays, keeping callbacks stable across rule edits and preserving
the memo() benefit on FilterRuleRow.

* improvement(tables,kb): remove hacky patterns, fix KB filter popover width

- Remove non-TSDoc comment from table-filter (rulesRef pattern is self-evident)
- Simplify SearchSection: remove setState-during-render anti-pattern; controlled
  input binds directly to search.value/onChange (simpler and correct)
- Reduce KB filter popover from w-[320px] to w-[200px]; tag filter uses vertical
  layout so narrow width works; Status-only case is now appropriately compact

* feat(knowledge): add sort and filter to KB list page

Sort dropdown: name, documents, tokens, created, last updated — pre-sorted
externally before passing rows to Resource. Active sort highlights the Sort
button; clear resets to default (created desc).

Filter popover: filter by connector status (All / With connectors /
Without connectors). Active filter shown as a removable tag in the toolbar.

* feat(files): add sort and filter to files list page

* feat(scheduled-tasks): add sort and filter to scheduled tasks page

* fix(table-filter): use explicit close handler instead of toggle

* improvement(files,knowledge): replace manual debounce with useDebounce hook and use type guards for file filtering

* fix(resource): prevent popover from inheriting anchor min-width

* feat(tables): add sort to tables list page

* feat(knowledge): add content and owner filters to KB list

* feat(scheduled-tasks): add status and health filters

* feat(files): add size and uploaded-by filters to files list

* feat(tables): add row count, owner, and column type filters

* improvement(scheduled-tasks): use combobox filter panel matching logs UI style

* improvement(knowledge): use combobox filter panel matching logs UI style

* improvement(files): use combobox filter panel matching logs UI style

Replaces button-list filters with Combobox-based multi-select sections for file type, size, and uploaded-by filters, aligning the panel with the logs page filter UI.

* improvement(tables): use combobox filter panel matching logs UI style

* feat(settings): add sort to recently deleted page

Add a sort dropdown next to the search bar allowing users to sort by deletion date (default, newest first), name (A–Z), or type (A–Z).

* feat(logs): add sort to logs page

* improvement(knowledge): upgrade document list filter to combobox style

* fix(resources): fix missing imports, memoization, and stale refs across resource pages

* improvement(tables): remove column type filter

* fix(resources): fix filter/sort correctness issues from audit

* fix(chunks): add server-side sort to document chunks API

Chunk sort was previously done client-side on a single page of
server-paginated data, which only reordered the current page.
Now sort params (sortBy, sortOrder) flow through the full stack:
types → service → API route → query hook → useDocumentChunks → document.tsx.

* perf(resources): memoize filterContent JSX across all resource pages

Resource is wrapped in React.memo, so an unstable filterContent reference
on every parent re-render defeats the memo. Wrap filterContent in useMemo
with correct deps in all 6 pages (files, tables, scheduled-tasks, knowledge,
base, document).

* fix(resources): add missing sort options for all visible columns

Every column visible in a resource table should be sortable. Three pages
had visible columns with no sort support:
- files.tsx: add 'owner' sort (member name lookup)
- scheduled-tasks.tsx: add 'schedule' sort (localeCompare on description)
- knowledge.tsx: add 'connectors' (count) and 'owner' (member name) sorts

Also add 'members' to processedKBs deps in knowledge.tsx since owner
sort now reads member names inside the memo.

* whitelabeling updates, sidebar fixes, files bug

* increased type safety

* pr fixes
2026-03-28 23:31:54 -07:00
Waleed
b9b930bb63 feat(analytics): add Profound web traffic tracking (#3835)
* feat(analytics): add Profound web traffic tracking

* fix(analytics): address PR review — add endpoint check and document trade-offs

* chore(analytics): remove implementation comments

* fix(analytics): guard sendToProfound with try-catch and align check with isProfoundEnabled

* fix(analytics): strip sensitive query params and remove redundant guard

* chore(analytics): remove unnecessary query param filtering
2026-03-28 22:09:23 -07:00
Vikhyath Mondreti
f1ead2ed55 fix docker image build 2026-03-28 20:58:56 -07:00
Waleed
30377d775b improvement(ui): sidebar (#3832) 2026-03-28 15:24:07 -07:00
Waleed
d013132d0e improvement(home): position @ mention popup at caret and fix icon consistency (#3831)
* improvement(home): position @ mention popup at caret and fix icon consistency

* fix(home): pin mirror div to document origin and guard button anchor

* chore(auth): restore hybrid.ts to staging
2026-03-28 14:48:41 -07:00
Waleed
7b0ce8064a feat(files): interactive markdown checkbox toggling in preview (#3829)
* feat(files): interactive markdown checkbox toggling in preview

* fix(files): handle ordered-list checkboxes and fix index drift

* lint

* fix(files): remove counter offset that prevented checkbox toggling

* fix(files): apply task-list styling to ordered lists too

* fix(files): render single pass when interactive to avoid index drift

* fix(files): move useMemo above conditional return to fix Rules of Hooks

* fix(files): pass content directly to preview when not streaming to avoid stale frame
2026-03-28 14:26:29 -07:00
Theodore Li
0ea73263df feat(ui): handle image paste (#3826)
* feat(ui): handle image paste

* Fix lint

* Fix type error

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-28 17:15:33 -04:00
Waleed
edc502384b improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830)
* improvement(sidebar): expand sidebar by hovering and clicking the edge

* improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA

* improvement(sidebar): use Tooltip.Shortcut for inline shortcut display

* fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict

* fix(hotkeys): fall back to event.code for international keyboard layout compatibility

* fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks
2026-03-28 14:06:30 -07:00
Waleed
e2be99263c feat(academy): Sim Academy — interactive partner certification platform (#3824)
* fix(import): dedup workflow name (#3813)

* feat(concurrency): bullmq based concurrency control system (#3605)

* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>

* fix(linear): add default null for after cursor (#3814)

* fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)

* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric

* fix(security): SSRF, access control, and info disclosure (#3815)

* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

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

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

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

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

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

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

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

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

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

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

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

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

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

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

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

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(worker): dockerfile + helm updates (#3818)

* fix(worker): dockerfile + helm updates

* address comments

* update dockerfile (#3819)

* fix dockerfile

* fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)

* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

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

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

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

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

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

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

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

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

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

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(worker): configuration defaults (#3821)

* improvement(worker): configuration defaults

* update readmes

* realtime curl import

* improvement(tour): remove auto-start, only trigger on explicit user action (#3823)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty

* mock course

* fix(db): use bigint for token counter columns in user_stats (#3755)

* mock course

* updates

* updated X handle for emir

* cleanup: audit and clean academy implementation

* fix(academy): add label to ValidationRule, fix quiz gating, simplify getRuleMessage

* cleanup: remove unnecessary comments across academy files

* refactor(academy): simplify abstractions and fix perf issues

* perf(academy): convert course detail page to server component with client island

* fix(academy): null-safe canAdvance, render exercise instructions, remove stale comments

* fix(academy): remove orphaned migration, fix getCourseById, clean up comments

- Delete 0181_academy_certificate.sql (orphaned duplicate not in journal)
- Add getCourseById() to content/index.ts; use it in certificates API
  (was using getCourse which searches by slug, not stable id)
- Remove JSX comments from catalog page
- Remove redundant `passed` recomputation in LessonQuiz

* chore(db): regenerate academy_certificate migration with drizzle-kit

* chore: include blog mdx and components changes

* fix(blog): correct cn import path

* fix(academy): constrain progress bar to max-w-3xl with proper padding

* feat(academy): show back-to-course button on first lesson

* fix(academy): force dark theme on all /academy routes

* content(academy): rewrite sim-foundations course with full 6-module curriculum

* fix(academy): correct edge handles, quiz explanation, and starter mock outputs

- Fix Exercise 2 initial edge handles: 'starter-1-source'/'agent-1-target' → 'source'/'target' (React Flow actual IDs)
- Fix M1-L4 Q4 quiz explanation: remove non-existent Ctrl/Cmd+D and Alt+drag shortcuts
- Add starter mock output to all exercises so run animation shows feedback on the first block

* refine(academy): fix inaccurate content and improve exercise clarity

- Fix Exercise 3: replace hardcoded <agent-1.content> (invalid UUID-based ref) with reference picker instructions
- Fix M4 Quiz Q5: Loop block (subflow container) is correct answer, not the Workflow block
- Fix M4 Quiz Q4: clarify fan-out vs Parallel block distinction in explanation
- Fix M4-L2 video description: accurately describe Loop and Parallel subflow blocks
- Fix M2 Quiz Q3: make response format question conceptual rather than syntax-specific
- Improve Exercise 4 branching instructions: clarify top=true / bottom=false output handles
- Improve Final Project instructions: step-by-step numbered flow

* fix(academy): remove double border on quiz question cards

* fix(academy): single scroll container on lesson pages — remove nested flex scroll

* fix(academy): remove min-h-screen from root layout — fixes double scrollbar on lesson pages

* fix(academy): use fixed inset-0 on lesson page to eliminate document-level scrollbar

* fix(academy): replace sr-only radio/checkbox inputs with buttons to prevent scroll-on-focus; restore layout min-h-screen

* improvement(academy): polish, security hardening, and certificate claim UI

- Replace raw localStorage with BrowserStorage utility in local-progress
- Pre-compute slug/id Maps in content/index for O(1) course lookups
- Move blockMap construction into edge_exists branch only in validation
- Extract navBtnClass constant and MetaRow/formatDate helpers in UI
- Add rate limiting, server-side completion verification, audit logging, and nanoid cert numbers to certificate issuance endpoint
- Add useIssueCertificate mutation hook with completedLessonIds
- Wire certificate claim UI into CourseProgress: sign-in prompt, claim button with loading state, and post-issuance view with link to certificate page
- Fix lesson page scroll container and quiz scroll-on-focus bug

* fix(academy): validate condition branch handles in edge_exists rules

- Add sourceHandle field to edge_exists ValidationRule type
- Check sourceHandle in validation.ts when specified
- Require both condition-if and condition-else branches to be connected in the branching and final project exercises

* fix(academy): address PR review — isHosted regression, stuck isExecuting, revoked cert 500, certificate SSR

- Restore env-var-based isHosted check (was hardcoded true, breaking self-hosted deployments)
- Fix isExecuting stuck at true when mock run fails validation — set isMockRunningRef immediately and reset both flags on early exit
- Fix revoked/expired certificate causing 500 — any existing record (not just active) now returns 409 instead of falling through to INSERT
- Convert certificate verification page from client component to server component — direct DB fetch, notFound() on missing cert, generateMetadata for SEO/social previews

* fix(auth): restore hybrid.ts from staging to fix CI type error

* fix(academy): mark video lessons complete on visit and fix sign-in path

* fix(academy): replace useEffect+setState with lazy useState initializer in CourseProgress

* fix(academy): reset exerciseComplete on lesson navigation, remove unused useAcademyCertificate hook

* fix(academy): useState for slug-change reset, cache() for cert page, handleMockRunRef for stale closure

* fix(academy): replace shadcn theme vars with explicit hex in LessonVideo fallback

* fix(academy): reset completedRef on exercise change, conditional verified badge, multi-select empty guard

* fix(academy): type safety fixes — null metadata fallbacks, returning() guard, exhaustive union, empty catch

* fix(academy): reset ExerciseView completed banner on nav; fix CourseProgress hydration mismatch

* fix(lightbox): guard effect body with isOpen to prevent spurious overflow reset

* fix(academy): reset LessonQuiz state on lesson change to prevent stale answers persisting

* fix(academy): course not-found metadata title; try-finally guard in mock run loop

* fix(academy): type safety, cert persistence, regex guard, mixed-lesson video, shorts support

- Derive AcademyCertificate from db $inferSelect to prevent schema drift
- Add useCourseCertificate query hook; GET /api/academy/certificates now accepts courseId for authenticated lookup
- Use useCourseCertificate in CourseProgress so certificate state survives page refresh
- Guard new RegExp(valuePattern) in validation.ts with try/catch; log warn on invalid pattern
- Add logger.warn for custom validation rules so content authors are alerted
- Add YouTube Shorts URL support to LessonVideo (youtube.com/shorts/VIDEO_ID)
- Fix mixed-lesson video gap: render videoUrl above quiz when mixed has quiz but no exercise
- Add academy-scoped not-found.tsx with link back to /academy

* fix(academy): reset hintIndex when exercise changes

* chore: remove ban-spam-accounts script (wrong branch)

* fix(academy): enforce availableBlocks in toolbar; fix mixed exercise+quiz rendering

- Add useSandboxBlockConstraints context; SandboxCanvasProvider provides exerciseConfig.availableBlocks so the toolbar only shows permitted block types. Empty array hides all blocks (configure-only exercises); non-null array restricts to listed types; triggers always hidden in sandbox.
- Fix mixed lesson with both exerciseConfig and quizConfig: exercise renders first, quiz reveals after exercise completes (sequential pedagogy). canAdvance now requires both exerciseComplete && quizComplete when both are present.

* chore(academy): remove extraneous inline comments

* fix(academy): blank mixed lesson, quiz canAdvance flag, empty-array valueNotEmpty

* prep for merge

* chore(db): regenerate academy certificate migration after staging merge

* fix(academy): disable auto-connect in sandbox mode

* fix(academy): render video in mixed lesson with no exercise or quiz

* fix(academy): mark mixed video-only lessons complete; handle cert insert race

* fix(canvas): add sandbox and embedded to nodes useMemo deps

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-28 12:46:04 -07:00
Waleed
f6b461ad47 fix(readme): restore readme gifs (#3827)
The static GIFs referenced by README.md were removed in #3803.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:36:23 -07:00
Waleed
e4d35735b1 fix(knowledge): give users choice to keep or delete documents when removing connector (#3825)
* fix(knowledge): give users choice to keep or delete documents when removing connector

* refactor(knowledge): clean up connector delete and extract shared extension validator

- Extract `isAlphanumericExtension` helper to deduplicate regex across parser-extension.ts and validation.ts
- Extract `closeDeleteModal` callback to eliminate 4x scattered state resets
- Add archivedAt/deletedAt filters to UPDATE query in keep-docs delete path
- Parallelize storage file cleanup and tag definition cleanup with Promise.all
- Deduplicate URL construction in delete connector hook

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

* refactor(knowledge): remove duplicate extension list from parser-extension

Use SUPPORTED_DOCUMENT_EXTENSIONS and isSupportedExtension from
validation.ts instead of maintaining a separate identical list.

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

* fix(db): change document.connectorId FK from cascade to set null

The cascade behavior meant deleting a connector would always delete
its documents, contradicting the "keep documents" option. With set null,
the database automatically nullifies connectorId when a connector is
removed, and we only need explicit deletion when the user opts in.

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

* chore(db): add migration metadata for connectorId FK change

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

* fix(knowledge): fix connector delete test and use URL-safe searchParams

Use `new URL(request.url).searchParams` instead of `request.nextUrl.searchParams`
for compatibility with test mocks. Add missing `connectorType` to test fixture.

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

* spacing

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:04:26 -07:00
Waleed
b4064c57fb fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)
* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty
2026-03-27 20:22:30 -07:00
Waleed
eac41ca105 improvement(tour): remove auto-start, only trigger on explicit user action (#3823) 2026-03-27 19:59:47 -07:00
Vikhyath Mondreti
d2c3c1c39e improvement(worker): configuration defaults (#3821)
* improvement(worker): configuration defaults

* update readmes

* realtime curl import
2026-03-27 19:54:56 -07:00
Waleed
8f3e864751 fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)
* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

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

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

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

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

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

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

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

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

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

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 19:54:00 -07:00
Vikhyath Mondreti
23c3072784 fix dockerfile 2026-03-27 19:11:02 -07:00
Vikhyath Mondreti
33fdb11396 update dockerfile (#3819) 2026-03-27 18:50:57 -07:00
Vikhyath Mondreti
21156dd54a fix(worker): dockerfile + helm updates (#3818)
* fix(worker): dockerfile + helm updates

* address comments
2026-03-27 18:28:36 -07:00
Waleed
c05e2e0fc8 fix(security): SSRF, access control, and info disclosure (#3815)
* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

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

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

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

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

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

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

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

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

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

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

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

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

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

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

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

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 18:10:47 -07:00
Waleed
a7c1e510e6 fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)
* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric
2026-03-27 16:58:07 -07:00
Vikhyath Mondreti
271624a402 fix(linear): add default null for after cursor (#3814) 2026-03-27 15:57:40 -07:00
Vikhyath Mondreti
dda012eae9 feat(concurrency): bullmq based concurrency control system (#3605)
* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-27 13:11:35 -07:00
Vikhyath Mondreti
2dd6d3d1e6 fix(import): dedup workflow name (#3813) 2026-03-27 13:09:49 -07:00
625 changed files with 62678 additions and 9098 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

@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
#### Background worker note
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
### Self-hosted: Manual Setup
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
5. Start development servers:
```bash
bun run dev:full # Starts both Next.js app and realtime socket server
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
```
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
## Copilot API Keys

View File

@@ -18,7 +18,7 @@ export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
template: '%s',
template: '%s | Sim Docs',
},
description:
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',

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}>
@@ -1285,6 +1324,17 @@ export function StartIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function ProfoundIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg width='1em' height='1em' viewBox='0 0 55 55' xmlns='http://www.w3.org/2000/svg' {...props}>
<path
fill='currentColor'
d='M0 36.685V21.349a7.017 7.017 0 0 1 2.906-5.69l19.742-14.25A7.443 7.443 0 0 1 27.004 0h.062c1.623 0 3.193.508 4.501 1.452l19.684 14.207a7.016 7.016 0 0 1 2.906 5.69v12.302a7.013 7.013 0 0 1-2.907 5.689L31.527 53.562A7.605 7.605 0 0 1 27.078 55a7.641 7.641 0 0 1-4.465-1.44c-2.581-1.859-6.732-4.855-6.732-4.855V29.777c0-.249.28-.393.482-.248l10.538 7.605c.106.077.249.077.355 0l13.005-9.386a.306.306 0 0 0 0-.496l-13.005-9.386a.303.303 0 0 0-.355 0L.482 36.933A.304.304 0 0 1 0 36.685Z'
/>
</svg>
)
}
export function PineconeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -2030,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'>
@@ -2141,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'>
@@ -4491,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,
@@ -126,6 +128,7 @@ import {
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
PulseIcon,
QdrantIcon,
QuiverIcon,
@@ -139,6 +142,7 @@ import {
S3Icon,
SalesforceIcon,
SearchIcon,
SecretsManagerIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -154,6 +158,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -220,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,
@@ -268,6 +274,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -302,6 +309,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quiver: QuiverIcon,
@@ -315,6 +323,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
secrets_manager: SecretsManagerIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
@@ -331,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

@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
### Concurrent Execution Limits
| Plan | Concurrent Executions |
|------|----------------------|
| **Free** | 5 |
| **Pro** | 50 |
| **Max / Team** | 200 |
| **Enterprise** | 200 (customizable) |
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
### File Storage
| Plan | Storage |

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

@@ -1,6 +1,6 @@
---
title: File
description: Read and parse multiple files
description: Read and write workspace files
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -27,7 +27,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
## Usage Instructions
Upload files directly or import from external URLs to get UserFile objects for use in other blocks.
Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.
@@ -52,4 +52,45 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| `files` | file[] | Parsed files as UserFile objects |
| `combinedContent` | string | Combined content of all parsed files |
### `file_write`
Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g.,
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | File name \(e.g., "data.csv"\). If a file with this name exists, a numeric suffix is added automatically. |
| `content` | string | Yes | The text content to write to the file. |
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from file extension if omitted. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |
### `file_append`
Append content to an existing workspace file. The file must already exist. Content is added to the end of the file.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of an existing workspace file to append to. |
| `content` | string | Yes | The text content to append to the file. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |

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",
@@ -121,6 +123,7 @@
"polymarket",
"postgresql",
"posthog",
"profound",
"pulse",
"qdrant",
"quiver",
@@ -134,6 +137,7 @@
"s3",
"salesforce",
"search",
"secrets_manager",
"sendgrid",
"sentry",
"serper",
@@ -151,6 +155,7 @@
"stt",
"supabase",
"table",
"tailscale",
"tavily",
"telegram",
"textract",

View File

@@ -0,0 +1,626 @@
---
title: Profound
description: AI visibility and analytics with Profound
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="profound"
color="#000000"
/>
{/* MANUAL-CONTENT-START:intro */}
[Profound](https://tryprofound.com/) is an AI visibility and analytics platform that helps brands understand how they appear across AI-powered search engines, chatbots, and assistants. It tracks mentions, citations, sentiment, bot traffic, and referral patterns across platforms like ChatGPT, Perplexity, Google AI Overviews, and more.
With the Profound integration in Sim, you can:
- **Monitor AI Visibility**: Track share of voice, visibility scores, and mention counts across AI platforms for your brand and competitors.
- **Analyze Sentiment**: Measure how positively or negatively your brand is discussed in AI-generated responses.
- **Track Citations**: See which URLs are being cited by AI models and your citation share relative to competitors.
- **Monitor Bot Traffic**: Analyze AI crawler activity on your domain, including GPTBot, ClaudeBot, and other AI agents, with hourly granularity.
- **Track Referral Traffic**: Monitor human visits arriving from AI platforms to your website.
- **Explore Prompt Data**: Access raw prompt-answer pairs, query fanouts, and prompt volume trends across AI platforms.
- **Optimize Content**: Get AEO (Answer Engine Optimization) scores and actionable recommendations to improve how AI models reference your content.
- **Manage Categories & Assets**: List and explore your tracked categories, assets (brands), topics, tags, personas, and regions.
These tools let your agents automate AI visibility monitoring, competitive intelligence, and content optimization workflows. To use the Profound integration, you'll need a Profound account with API access.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.
## Tools
### `profound_list_categories`
List all organization categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `categories` | json | List of organization categories |
| ↳ `id` | string | Category ID |
| ↳ `name` | string | Category name |
### `profound_list_regions`
List all organization regions in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `regions` | json | List of organization regions |
| ↳ `id` | string | Region ID \(UUID\) |
| ↳ `name` | string | Region name |
### `profound_list_models`
List all AI models/platforms tracked in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `models` | json | List of AI models/platforms |
| ↳ `id` | string | Model ID \(UUID\) |
| ↳ `name` | string | Model/platform name |
### `profound_list_domains`
List all organization domains in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `domains` | json | List of organization domains |
| ↳ `id` | string | Domain ID \(UUID\) |
| ↳ `name` | string | Domain name |
| ↳ `createdAt` | string | When the domain was added |
### `profound_list_assets`
List all organization assets (companies/brands) across all categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assets` | json | List of organization assets with category info |
| ↳ `id` | string | Asset ID |
| ↳ `name` | string | Asset/company name |
| ↳ `website` | string | Asset website URL |
| ↳ `alternateDomains` | json | Alternate domain names |
| ↳ `isOwned` | boolean | Whether this asset is owned by the organization |
| ↳ `createdAt` | string | When the asset was created |
| ↳ `logoUrl` | string | URL of the asset logo |
| ↳ `categoryId` | string | Category ID the asset belongs to |
| ↳ `categoryName` | string | Category name |
### `profound_list_personas`
List all organization personas across all categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `personas` | json | List of organization personas with profile details |
| ↳ `id` | string | Persona ID |
| ↳ `name` | string | Persona name |
| ↳ `categoryId` | string | Category ID |
| ↳ `categoryName` | string | Category name |
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
### `profound_category_topics`
List topics for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `topics` | json | List of topics in the category |
| ↳ `id` | string | Topic ID \(UUID\) |
| ↳ `name` | string | Topic name |
### `profound_category_tags`
List tags for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tags` | json | List of tags in the category |
| ↳ `id` | string | Tag ID \(UUID\) |
| ↳ `name` | string | Tag name |
### `profound_category_prompts`
List prompts for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `limit` | number | No | Maximum number of results \(default 10000, max 10000\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `orderDir` | string | No | Sort direction: asc or desc \(default desc\) |
| `promptType` | string | No | Comma-separated prompt types to filter: visibility, sentiment |
| `topicId` | string | No | Comma-separated topic IDs \(UUIDs\) to filter by |
| `tagId` | string | No | Comma-separated tag IDs \(UUIDs\) to filter by |
| `regionId` | string | No | Comma-separated region IDs \(UUIDs\) to filter by |
| `platformId` | string | No | Comma-separated platform IDs \(UUIDs\) to filter by |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of prompts |
| `nextCursor` | string | Cursor for next page of results |
| `prompts` | json | List of prompts |
| ↳ `id` | string | Prompt ID |
| ↳ `prompt` | string | Prompt text |
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
| ↳ `topicId` | string | Topic ID |
| ↳ `topicName` | string | Topic name |
| ↳ `tags` | json | Associated tags |
| ↳ `regions` | json | Associated regions |
| ↳ `platforms` | json | Associated platforms |
| ↳ `createdAt` | string | When the prompt was created |
### `profound_category_assets`
List assets (companies/brands) for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assets` | json | List of assets in the category |
| ↳ `id` | string | Asset ID |
| ↳ `name` | string | Asset/company name |
| ↳ `website` | string | Website URL |
| ↳ `alternateDomains` | json | Alternate domain names |
| ↳ `isOwned` | boolean | Whether the asset is owned by the organization |
| ↳ `createdAt` | string | When the asset was created |
| ↳ `logoUrl` | string | URL of the asset logo |
### `profound_category_personas`
List personas for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `personas` | json | List of personas in the category |
| ↳ `id` | string | Persona ID |
| ↳ `name` | string | Persona name |
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
### `profound_visibility_report`
Query AI visibility report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: share_of_voice, mentions_count, visibility_score, executions, average_position |
| `dimensions` | string | No | Comma-separated dimensions: date, region, topic, model, asset_name, prompt, tag, persona |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_sentiment_report`
Query sentiment report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: positive, negative, occurrences |
| `dimensions` | string | No | Comma-separated dimensions: theme, date, region, topic, model, asset_name, tag, prompt, sentiment_type, persona |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_citations_report`
Query citations report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: count, citation_share |
| `dimensions` | string | No | Comma-separated dimensions: hostname, path, date, region, topic, model, tag, prompt, url, root_domain, persona, citation_category |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"hostname","operator":"is","value":"example.com"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_query_fanouts`
Query fanout report showing how AI models expand prompts into sub-queries in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: fanouts_per_execution, total_fanouts, share |
| `dimensions` | string | No | Comma-separated dimensions: prompt, query, model, region, date |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_prompt_answers`
Get raw prompt answers data for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"prompt_type","operator":"is","value":"visibility"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of answer rows |
| `data` | json | Raw prompt answer data |
| ↳ `prompt` | string | The prompt text |
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
| ↳ `response` | string | AI model response text |
| ↳ `mentions` | json | Companies/assets mentioned in the response |
| ↳ `citations` | json | URLs cited in the response |
| ↳ `topic` | string | Topic name |
| ↳ `region` | string | Region name |
| ↳ `model` | string | AI model/platform name |
| ↳ `asset` | string | Asset name |
| ↳ `createdAt` | string | Timestamp when the answer was collected |
### `profound_bots_report`
Query bot traffic report with hourly granularity for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query bot traffic for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `metrics` | string | Yes | Comma-separated metrics: count, citations, indexing, training, last_visit |
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, bot_name, bot_provider, bot_type |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_referrals_report`
Query human referral traffic report with hourly granularity for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query referral traffic for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `metrics` | string | Yes | Comma-separated metrics: visits, last_visit |
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, referral_source, referral_type |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"referral_source","operator":"is","value":"openai"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_raw_logs`
Get raw traffic logs with filters for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query logs for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"path","operator":"contains","value":"/blog"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of log entries |
| `data` | json | Log data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values \(count\) |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_bot_logs`
Get identified bot visit logs with filters for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query bot logs for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params, bot_name, bot_provider, bot_types |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of bot log entries |
| `data` | json | Bot log data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values \(count\) |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_list_optimizations`
List content optimization entries for an asset in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `assetId` | string | Yes | Asset ID \(UUID\) |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
| `offset` | number | No | Offset for pagination \(default 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of optimization entries |
| `optimizations` | json | List of content optimization entries |
| ↳ `id` | string | Optimization ID \(UUID\) |
| ↳ `title` | string | Content title |
| ↳ `createdAt` | string | When the optimization was created |
| ↳ `extractedInput` | string | Extracted input text |
| ↳ `type` | string | Content type: file, text, or url |
| ↳ `status` | string | Optimization status |
### `profound_optimization_analysis`
Get detailed content optimization analysis for a specific content item in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `assetId` | string | Yes | Asset ID \(UUID\) |
| `contentId` | string | Yes | Content/optimization ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | json | The analyzed content |
| ↳ `format` | string | Content format: markdown or html |
| ↳ `value` | string | Content text |
| `aeoContentScore` | json | AEO content score with target zone |
| ↳ `value` | number | AEO score value |
| ↳ `targetZone` | json | Target zone range |
| ↳ `low` | number | Low end of target range |
| ↳ `high` | number | High end of target range |
| `analysis` | json | Analysis breakdown by category |
| ↳ `breakdown` | json | Array of scoring breakdowns |
| ↳ `title` | string | Category title |
| ↳ `weight` | number | Category weight |
| ↳ `score` | number | Category score |
| `recommendations` | json | Content optimization recommendations |
| ↳ `title` | string | Recommendation title |
| ↳ `status` | string | Status: done or pending |
| ↳ `impact` | json | Impact details with section and score |
| ↳ `suggestion` | json | Suggestion text and rationale |
| ↳ `text` | string | Suggestion text |
| ↳ `rationale` | string | Why this recommendation matters |
### `profound_prompt_volume`
Query prompt volume data to understand search demand across AI platforms in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: volume, change |
| `dimensions` | string | No | Comma-separated dimensions: keyword, date, platform, country_code, matching_type, frequency |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"keyword","operator":"contains","value":"best"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Volume data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_citation_prompts`
Get prompts that cite a specific domain across AI platforms in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `inputDomain` | string | Yes | Domain to look up citations for \(e.g. ramp.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Citation prompt data for the queried domain |

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

@@ -1,3 +1,6 @@
/** Shared className for primary auth form submit buttons across all auth pages. */
export const AUTH_SUBMIT_BTN =
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
export const AUTH_PRIMARY_CTA_BASE =
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Full-width variant used for primary auth form submit buttons. */
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const

View File

@@ -288,7 +288,6 @@ export default function Collaboration() {
width={876}
height={480}
className='h-full w-auto object-left md:min-w-[100vw]'
priority
/>
</div>
<div className='hidden lg:block'>

View File

@@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
)
}
interface FeatureToggleItemProps {
feature: PermissionFeature
enabled: boolean
color: string
isInView: boolean
delay: number
textClassName: string
transition: Record<string, unknown>
onToggle: () => void
}
function FeatureToggleItem({
feature,
enabled,
color,
isInView,
delay,
textClassName,
transition,
onToggle,
}: FeatureToggleItemProps) {
return (
<motion.div
key={feature.key}
role='button'
tabIndex={0}
aria-label={`Toggle ${feature.name}`}
aria-pressed={enabled}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ ...transition, delay }}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
{feature.name}
</span>
</motion.div>
)
}
export function AccessControlPanel() {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-40px' })
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
return (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
return (
<motion.div
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
duration: 0.3,
}}
onClick={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
)
})}
{category.features.map((feature, featIdx) => (
<FeatureToggleItem
key={feature.key}
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.05 + (offsetBefore + featIdx) * 0.04}
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
transition={{ duration: 0.3 }}
onToggle={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
/>
))}
</div>
</div>
)
@@ -140,12 +176,11 @@ export function AccessControlPanel() {
<div className='hidden lg:block'>
{PERMISSION_CATEGORIES.map((category, catIdx) => (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
const currentIndex =
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
(sum, c) => sum + c.features.length,
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
) + featIdx
return (
<motion.div
<FeatureToggleItem
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.1 + currentIndex * 0.04,
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
}}
onClick={() =>
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.1 + currentIndex * 0.04}
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
onToggle={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
/>
)
})}
</div>

View File

@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
</div>
{/* Time */}
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
{timeAgo}
</span>
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
<span className='hidden sm:inline'>
<span className='text-[#F6F6F6]/40'> · </span>
<span className='text-[#F6F6F6]/60'> · </span>
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
</span>
</span>

View File

@@ -85,7 +85,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SOC 2 & HIPAA
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
Type II · PHI protected
</span>
</div>
@@ -105,7 +105,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
Open Source
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
View on GitHub
</span>
</div>
@@ -120,7 +120,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SSO & SCIM
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
Okta, Azure AD, Google
</span>
</div>
@@ -165,7 +165,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Audit Trail
</h3>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
Every action is captured with full actor attribution.
</p>
</div>
@@ -179,7 +179,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Access Control
</h3>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
Restrict providers, surfaces, and tools per group.
</p>
</div>
@@ -211,7 +211,7 @@ export default function Enterprise() {
(tag, i) => (
<span
key={i}
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
>
{tag}
</span>
@@ -221,7 +221,7 @@ export default function Enterprise() {
</div>
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<DemoRequestModal>

View File

@@ -190,7 +190,6 @@ export default function Features() {
width={1440}
height={366}
className='h-auto w-full'
priority
/>
</div>

View File

@@ -67,6 +67,7 @@ export function FooterCTA() {
type='button'
onClick={handleSubmit}
disabled={isEmpty}
aria-label='Submit message'
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#C0C0C0' : '#1C1C1C',

View File

@@ -26,6 +26,8 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
// { label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
// { label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
]

View File

@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
'5GB file storage',
'3 tables · 1,000 rows each',
'5 min execution limit',
'5 concurrent/workspace',
'7-day log retention',
'CLI/SDK/MCP Access',
],
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
'50GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 150 runs/min',
'50 concurrent/workspace',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
'500GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 300 runs/min',
'200 concurrent/workspace',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
@@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
'Custom file storage',
'10,000 tables · 1M rows each',
'Custom execution limits',
'Custom concurrency limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',

View File

@@ -0,0 +1,43 @@
'use client'
import { useState } from 'react'
import NextImage from 'next/image'
import { cn } from '@/lib/core/utils/cn'
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
interface BlogImageProps {
src: string
alt?: string
width?: number
height?: number
className?: string
}
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
return (
<>
<NextImage
src={src}
alt={alt}
width={width}
height={height}
className={cn(
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
className
)}
sizes='(max-width: 768px) 100vw, 800px'
loading='lazy'
unoptimized
onClick={() => setIsLightboxOpen(true)}
/>
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
/>
</>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useRef } from 'react'
interface LightboxProps {
isOpen: boolean
onClose: () => void
src: string
alt: string
}
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
const handleClickOutside = (event: MouseEvent) => {
if (overlayRef.current && event.target === overlayRef.current) {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
role='dialog'
aria-modal='true'
aria-label='Image viewer'
>
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
<img
src={src}
alt={alt}
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
loading='lazy'
onClick={onClose}
/>
</div>
</div>
)
}

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,
@@ -126,6 +128,7 @@ import {
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
PulseIcon,
QdrantIcon,
QuiverIcon,
@@ -139,6 +142,7 @@ import {
S3Icon,
SalesforceIcon,
SearchIcon,
SecretsManagerIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -154,6 +158,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -220,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,
@@ -268,6 +274,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -302,6 +309,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quiver: QuiverIcon,
@@ -315,6 +323,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
secrets_manager: SecretsManagerIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
@@ -331,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",
@@ -2993,13 +3074,26 @@
"type": "file_v3",
"slug": "file",
"name": "File",
"description": "Read and parse multiple files",
"longDescription": "Upload files directly or import from external URLs to get UserFile objects for use in other blocks.",
"description": "Read and write workspace files",
"longDescription": "Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.",
"bgColor": "#40916C",
"iconName": "DocumentIcon",
"docsUrl": "https://docs.sim.ai/tools/file",
"operations": [],
"operationCount": 0,
"operations": [
{
"name": "Read",
"description": "Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)"
},
{
"name": "Write",
"description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "
},
{
"name": "Append",
"description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file."
}
],
"operationCount": 3,
"triggers": [],
"triggerCount": 0,
"authType": "none",
@@ -6287,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",
@@ -8611,6 +8772,121 @@
"integrationType": "analytics",
"tags": ["data-analytics", "monitoring"]
},
{
"type": "profound",
"slug": "profound",
"name": "Profound",
"description": "AI visibility and analytics with Profound",
"longDescription": "Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.",
"bgColor": "#000000",
"iconName": "ProfoundIcon",
"docsUrl": "https://docs.sim.ai/tools/profound",
"operations": [
{
"name": "List Categories",
"description": "List all organization categories in Profound"
},
{
"name": "List Regions",
"description": "List all organization regions in Profound"
},
{
"name": "List Models",
"description": "List all AI models/platforms tracked in Profound"
},
{
"name": "List Domains",
"description": "List all organization domains in Profound"
},
{
"name": "List Assets",
"description": "List all organization assets (companies/brands) across all categories in Profound"
},
{
"name": "List Personas",
"description": "List all organization personas across all categories in Profound"
},
{
"name": "Category Topics",
"description": "List topics for a specific category in Profound"
},
{
"name": "Category Tags",
"description": "List tags for a specific category in Profound"
},
{
"name": "Category Prompts",
"description": "List prompts for a specific category in Profound"
},
{
"name": "Category Assets",
"description": "List assets (companies/brands) for a specific category in Profound"
},
{
"name": "Category Personas",
"description": "List personas for a specific category in Profound"
},
{
"name": "Visibility Report",
"description": "Query AI visibility report for a category in Profound"
},
{
"name": "Sentiment Report",
"description": "Query sentiment report for a category in Profound"
},
{
"name": "Citations Report",
"description": "Query citations report for a category in Profound"
},
{
"name": "Query Fanouts",
"description": "Query fanout report showing how AI models expand prompts into sub-queries in Profound"
},
{
"name": "Prompt Answers",
"description": "Get raw prompt answers data for a category in Profound"
},
{
"name": "Bots Report",
"description": "Query bot traffic report with hourly granularity for a domain in Profound"
},
{
"name": "Referrals Report",
"description": "Query human referral traffic report with hourly granularity for a domain in Profound"
},
{
"name": "Raw Logs",
"description": "Get raw traffic logs with filters for a domain in Profound"
},
{
"name": "Bot Logs",
"description": "Get identified bot visit logs with filters for a domain in Profound"
},
{
"name": "List Optimizations",
"description": "List content optimization entries for an asset in Profound"
},
{
"name": "Optimization Analysis",
"description": "Get detailed content optimization analysis for a specific content item in Profound"
},
{
"name": "Prompt Volume",
"description": "Query prompt volume data to understand search demand across AI platforms in Profound"
},
{
"name": "Citation Prompts",
"description": "Get prompts that cite a specific domain across AI platforms in Profound"
}
],
"operationCount": 24,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "analytics",
"tags": ["seo", "data-analytics"]
},
{
"type": "pulse_v2",
"slug": "pulse",
@@ -10354,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

@@ -0,0 +1,292 @@
import type { Metadata } from 'next'
import { getNavBlogPosts } from '@/lib/blog/registry'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export const metadata: Metadata = {
title: 'Partner Program',
description:
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Partner Program | Sim',
description: 'Join the Sim partner program.',
type: 'website',
},
}
const PARTNER_TIERS = [
{
name: 'Certified Partner',
badge: 'Entry',
color: '#3A3A3A',
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
perks: [
'Official partner badge',
'Listed in partner directory',
'Early access to new features',
],
},
{
name: 'Silver Partner',
badge: 'Growth',
color: '#5A5A5A',
requirements: [
'All Certified requirements',
'3+ active client deployments',
'Sim Academy advanced certification',
],
perks: [
'All Certified perks',
'Dedicated partner Slack channel',
'Co-marketing opportunities',
'Priority support',
],
},
{
name: 'Gold Partner',
badge: 'Premier',
color: '#8B7355',
requirements: [
'All Silver requirements',
'10+ active client deployments',
'Sim solutions architect certification',
],
perks: [
'All Silver perks',
'Revenue share program',
'Joint case studies',
'Dedicated partner success manager',
'Influence product roadmap',
],
},
]
const HOW_IT_WORKS = [
{
step: '01',
title: 'Sign up & complete Sim Academy',
description:
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
},
{
step: '02',
title: 'Build & deploy real solutions',
description:
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
},
{
step: '03',
title: 'Get certified & grow',
description:
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
},
]
const BENEFITS = [
{
icon: '🎓',
title: 'Interactive Learning',
description:
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
},
{
icon: '🤝',
title: 'Co-Marketing',
description:
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
},
{
icon: '💰',
title: 'Revenue Share',
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
},
{
icon: '🚀',
title: 'Early Access',
description:
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
},
{
icon: '🛠️',
title: 'Technical Support',
description:
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
},
{
icon: '📣',
title: 'Community',
description:
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
},
]
export default async function PartnersPage() {
const blogPosts = await getNavBlogPosts()
return (
<div
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
>
<header>
<Navbar logoOnly={false} blogPosts={blogPosts} />
</header>
<main>
{/* Hero */}
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
<div className='mx-auto max-w-4xl'>
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner Program
</div>
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
Build the future
<br />
of AI automation
</h1>
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
recognition in the growing ecosystem of AI workflow builders.
</p>
<div className='flex items-center gap-4'>
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy →
</Link> */}
<a
href='#how-it-works'
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
>
Learn more
</a>
</div>
</div>
</section>
{/* Benefits grid */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Why partner with Sim
</div>
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
{BENEFITS.map((b) => (
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
<div className='mb-3 text-[24px]'>{b.icon}</div>
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-4xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
How it works
</div>
<div className='space-y-10'>
{HOW_IT_WORKS.map((step) => (
<div key={step.step} className='flex gap-8'>
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
{step.step}
</div>
<div className='pt-2'>
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Partner tiers */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner tiers
</div>
<div className='grid gap-5 lg:grid-cols-3'>
{PARTNER_TIERS.map((tier) => (
<div
key={tier.name}
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
<span
className='rounded-full px-2.5 py-0.5 text-[11px]'
style={{
backgroundColor: `${tier.color}33`,
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
border: `1px solid ${tier.color}`,
}}
>
{tier.badge}
</span>
</div>
<div className='mb-4'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
Requirements
</p>
<ul className='space-y-1.5'>
{tier.requirements.map((r) => (
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
{r}
</li>
))}
</ul>
</div>
<div className='mt-auto'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
<ul className='space-y-1.5'>
{tier.perks.map((p) => (
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
{p}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className='px-[80px] py-[100px]'>
<div className='mx-auto max-w-3xl text-center'>
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
Ready to get started?
</h2>
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
Complete Sim Academy to earn your first certification and unlock partner benefits.
It's free to start — no credit card required.
</p>
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy
</Link> */}
</div>
</section>
</main>
<Footer />
</div>
)
}

View File

@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
const isDarkModePage = pathname.startsWith('/academy')
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
return (
<NextThemesProvider
attribute='class'
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={isLightModePage ? 'light' : undefined}
forcedTheme={forcedTheme}
{...props}
>
{children}

View File

@@ -15,6 +15,12 @@
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
--auth-primary-btn-bg: #ffffff;
--auth-primary-btn-border: #ffffff;
--auth-primary-btn-text: #000000;
--auth-primary-btn-hover-bg: #e0e0e0;
--auth-primary-btn-hover-border: #e0e0e0;
--auth-primary-btn-hover-text: #000000;
/* z-index scale for layered UI
Popover must be above modal so dropdowns inside modals render correctly */

View File

@@ -0,0 +1,156 @@
'use client'
import { useEffect, useState } from 'react'
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { getCompletedLessons } from '@/lib/academy/local-progress'
import type { Course } from '@/lib/academy/types'
import { useSession } from '@/lib/auth/auth-client'
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
interface CourseProgressProps {
course: Course
courseSlug: string
}
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
useEffect(() => {
setCompletedIds(getCompletedLessons())
}, [])
const { data: session } = useSession()
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
const certificate = fetchedCert ?? issuedCert
const allLessons = course.modules.flatMap((m) => m.lessons)
const totalLessons = allLessons.length
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
return (
<>
{completedCount > 0 && (
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
<div className='mb-2 flex items-center justify-between text-[13px]'>
<span className='text-[#999]'>Your progress</span>
<span className='text-[#ECECEC]'>
{completedCount}/{totalLessons} lessons
</span>
</div>
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
<div
className='h-full rounded-full bg-[#ECECEC] transition-all'
style={{ width: `${percentComplete}%` }}
/>
</div>
</div>
</div>
)}
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl space-y-10'>
{course.modules.map((mod, modIndex) => (
<div key={mod.id}>
<div className='mb-4 flex items-center gap-3'>
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
<div className='h-px flex-1 bg-[#2A2A2A]' />
</div>
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
<div className='space-y-2'>
{mod.lessons.map((lesson) => (
<Link
key={lesson.id}
href={`/academy/${courseSlug}/${lesson.slug}`}
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
>
{completedIds.has(lesson.id) ? (
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
)}
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
{lesson.videoDurationSeconds && (
<span className='text-[#555] text-[12px]'>
{Math.round(lesson.videoDurationSeconds / 60)} min
</span>
)}
</Link>
))}
</div>
</div>
))}
</div>
</section>
{totalLessons > 0 && completedCount === totalLessons && (
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
{certificate ? (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
<p className='font-mono text-[#666] text-[13px]'>
{certificate.certificateNumber}
</p>
</div>
</div>
<Link
href={`/academy/certificate/${certificate.certificateNumber}`}
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
>
View certificate
<ExternalLink className='h-3.5 w-3.5' />
</Link>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
<p className='text-[#666] text-[13px]'>
{session
? error
? 'Something went wrong. Try again.'
: 'Claim your certificate of completion.'
: 'Sign in to claim your certificate.'}
</p>
</div>
</div>
{session ? (
<button
type='button'
disabled={isPending}
onClick={() =>
issueCertificate({
courseId: course.id,
completedLessonIds: [...completedIds],
})
}
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
>
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
{isPending ? 'Issuing…' : 'Get certificate'}
</button>
) : (
<Link
href='/login'
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
>
Sign in
</Link>
)}
</div>
)}
</div>
</section>
)}
</>
)
}

View File

@@ -0,0 +1,68 @@
import { Clock, GraduationCap } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { COURSES, getCourse } from '@/lib/academy/content'
import { CourseProgress } from './components/course-progress'
interface CourseDetailPageProps {
params: Promise<{ courseSlug: string }>
}
export function generateStaticParams() {
return COURSES.map((course) => ({ courseSlug: course.slug }))
}
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) return { title: 'Course Not Found' }
return {
title: course.title,
description: course.description,
}
}
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) notFound()
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<Link
href='/academy'
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
>
All courses
</Link>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
{course.title}
</h1>
{course.description && (
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
{course.description}
</p>
)}
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3.5 w-3.5' />
{course.estimatedMinutes} min total
</span>
)}
<span className='flex items-center gap-1.5'>
<GraduationCap className='h-3.5 w-3.5' />
Certificate upon completion
</span>
</div>
</div>
</section>
<CourseProgress course={course} courseSlug={courseSlug} />
</main>
)
}

View File

@@ -0,0 +1,127 @@
import { cache } from 'react'
import { db } from '@sim/db'
import { academyCertificate } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import type { AcademyCertificate } from '@/lib/academy/types'
interface CertificatePageProps {
params: Promise<{ certificateNumber: string }>
}
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) return { title: 'Certificate Not Found' }
return {
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
}
}
const fetchCertificate = cache(
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
const [row] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
return (row as unknown as AcademyCertificate) ?? null
}
)
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
}
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className='flex items-center justify-between px-5 py-3.5'>
<span className='text-[#666] text-[13px]'>{label}</span>
{children}
</div>
)
}
export default async function CertificatePage({ params }: CertificatePageProps) {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) notFound()
return (
<main className='flex flex-1 items-center justify-center px-6 py-20'>
<div className='w-full max-w-2xl'>
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
<div className='mb-6 flex justify-center'>
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
</div>
</div>
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
Certificate of Completion
</div>
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
{certificate.metadata?.courseTitle}
</h1>
{certificate.metadata?.recipientName && (
<p className='mb-6 text-[#999] text-[16px]'>
Awarded to{' '}
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
</p>
)}
{certificate.status === 'active' ? (
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
<CheckCircle2 className='h-4 w-4' />
<span className='font-[430] text-[14px]'>Verified</span>
</div>
) : (
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
<XCircle className='h-4 w-4' />
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
</div>
)}
</div>
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
<MetaRow label='Certificate number'>
<span className='font-mono text-[#ECECEC] text-[13px]'>
{certificate.certificateNumber}
</span>
</MetaRow>
<MetaRow label='Issued'>
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
</MetaRow>
<MetaRow label='Status'>
<span
className={`text-[13px] capitalize ${
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
}`}
>
{certificate.status}
</span>
</MetaRow>
{certificate.expiresAt && (
<MetaRow label='Expires'>
<span className='text-[#ECECEC] text-[13px]'>
{formatDate(certificate.expiresAt)}
</span>
</MetaRow>
)}
</div>
<p className='mt-5 text-center text-[#555] text-[13px]'>
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
{certificate.metadata?.courseTitle} program.
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,16 @@
import type React from 'react'
import { getNavBlogPosts } from '@/lib/blog/registry'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
return (
<>
<Navbar blogPosts={blogPosts} />
{children}
<Footer hideCTA />
</>
)
}

View File

@@ -0,0 +1,19 @@
import Link from 'next/link'
export default function AcademyNotFound() {
return (
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
<p className='mb-8 text-[#666] text-[15px]'>
That course or lesson doesn't exist in the Academy.
</p>
<Link
href='/academy'
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
>
Back to Academy
</Link>
</main>
)
}

View File

@@ -0,0 +1,76 @@
import { BookOpen, Clock } from 'lucide-react'
import Link from 'next/link'
import { COURSES } from '@/lib/academy/content'
export default function AcademyCatalogPage() {
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
Sim Academy
</div>
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
Become a certified
<br />
Sim partner
</h1>
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Master AI workflow automation with hands-on interactive exercises on the real Sim
canvas. Complete the program to earn your partner certification.
</p>
</div>
</section>
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-6xl'>
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
{COURSES.map((course) => {
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
return (
<Link
key={course.id}
href={`/academy/${course.slug}`}
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
>
{course.imageUrl && (
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
<img
src={course.imageUrl}
alt={course.title}
className='h-full w-full object-cover opacity-80'
/>
</div>
)}
<div className='flex-1'>
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
{course.title}
</h3>
{course.description && (
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
{course.description}
</p>
)}
</div>
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3 w-3' />
{course.estimatedMinutes} min
</span>
)}
<span className='flex items-center gap-1.5'>
<BookOpen className='h-3 w-3' />
{totalLessons} lessons
</span>
</div>
</Link>
)
})}
</div>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useCallback, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
interface ExerciseViewProps {
lessonId: string
exerciseConfig: ExerciseDefinition
onComplete?: () => void
videoUrl?: string
description?: string
}
/**
* Orchestrates the sandbox canvas for an exercise lesson.
* Completion is determined client-side by the validation engine and persisted to localStorage.
*/
export function ExerciseView({
lessonId,
exerciseConfig,
onComplete,
videoUrl,
description,
}: ExerciseViewProps) {
const [completed, setCompleted] = useState(false)
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setCompleted(false)
}
const handleComplete = useCallback(
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
setCompleted(true)
markLessonComplete(lessonId)
onComplete?.()
},
[lessonId, onComplete]
)
return (
<div className='relative flex h-full w-full flex-col overflow-hidden'>
<SandboxCanvasProvider
exerciseId={lessonId}
exerciseConfig={exerciseConfig}
onComplete={handleComplete}
videoUrl={videoUrl}
description={description}
className='flex-1'
/>
{completed && (
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
<CheckCircle2 className='h-4 w-4' />
Exercise complete!
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,256 @@
'use client'
import { useState } from 'react'
import { CheckCircle2, XCircle } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface LessonQuizProps {
lessonId: string
quizConfig: QuizDefinition
onPass?: () => void
}
type Answers = Record<number, number | number[] | boolean>
interface QuizResult {
score: number
passed: boolean
feedback: Array<{ correct: boolean; explanation?: string }>
}
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
const feedback = questions.map((q, i) => {
const answer = answers[i]
let correct = false
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
else if (q.type === 'true_false') correct = answer === q.correctAnswer
else if (q.type === 'multi_select') {
const selected = (answer as number[] | undefined) ?? []
correct =
selected.length === q.correctIndices.length &&
selected.every((v) => q.correctIndices.includes(v))
} else {
const _exhaustive: never = q
void _exhaustive
}
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
})
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
return { score, passed: score >= passingScore, feedback }
}
const optionBase =
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
/**
* Interactive quiz component with per-question feedback and retry support.
* Scoring is performed entirely client-side.
*/
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
const [answers, setAnswers] = useState<Answers>({})
const [result, setResult] = useState<QuizResult | null>(null)
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setAnswers({})
setResult(null)
}
const handleAnswer = (qi: number, value: number | boolean) => {
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
}
const handleMultiSelect = (qi: number, oi: number) => {
if (result) return
setAnswers((prev) => {
const current = (prev[qi] as number[] | undefined) ?? []
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
return { ...prev, [qi]: next }
})
}
const allAnswered = quizConfig.questions.every((q, i) => {
if (q.type === 'multi_select')
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
return answers[i] !== undefined
})
const handleSubmit = () => {
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
setResult(scored)
if (scored.passed) {
markLessonComplete(lessonId)
onPass?.()
}
}
return (
<div className='space-y-6'>
<div>
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
<p className='mt-1 text-[#666] text-[14px]'>
Score {quizConfig.passingScore}% or higher to pass.
</p>
</div>
{quizConfig.questions.map((q, qi) => {
const feedback = result?.feedback[qi]
const isCorrect = feedback?.correct
return (
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
{q.type === 'multiple_choice' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => (
<button
key={oi}
type='button'
onClick={() => handleAnswer(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
answers[qi] === oi
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
oi === q.correctIndex &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === oi &&
oi !== q.correctIndex &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
))}
</div>
)}
{q.type === 'true_false' && (
<div className='flex gap-3'>
{(['True', 'False'] as const).map((label) => {
const val = label === 'True'
return (
<button
key={label}
type='button'
onClick={() => handleAnswer(qi, val)}
disabled={Boolean(result)}
className={cn(
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
answers[qi] === val
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
val === q.correctAnswer &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === val &&
val !== q.correctAnswer &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{label}
</button>
)
})}
</div>
)}
{q.type === 'multi_select' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => {
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
return (
<button
key={oi}
type='button'
onClick={() => handleMultiSelect(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
selected
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
q.correctIndices.includes(oi) &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
selected &&
!q.correctIndices.includes(oi) &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
)
})}
</div>
)}
{feedback && (
<div
className={cn(
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
)}
>
{isCorrect ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
) : (
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
)}
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
</div>
)}
</div>
)
})}
{result && (
<div
className={cn(
'rounded-[8px] border p-5',
result.passed
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
)}
>
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
<p className='mt-1 text-[13px] opacity-80'>
Score: {result.score}% (passing: {quizConfig.passingScore}%)
</p>
{!result.passed && (
<button
type='button'
onClick={() => {
setAnswers({})
setResult(null)
}}
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
>
Retry
</button>
)}
</div>
)}
{!result && (
<button
type='button'
onClick={handleSubmit}
disabled={!allAnswered}
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
>
Submit answers
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
interface LessonLayoutProps {
children: React.ReactNode
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
/**
* Server-side auth gate for lesson pages.
* Redirects unauthenticated users to login before any client JS runs.
*/
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
const session = await getSession()
if (!session?.user?.id) {
const { courseSlug, lessonSlug } = await params
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
}
return <>{children}</>
}

View File

@@ -0,0 +1,219 @@
'use client'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { getCourse } from '@/lib/academy/content'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { Lesson } from '@/lib/academy/types'
import { LessonVideo } from '@/app/academy/components/lesson-video'
import { ExerciseView } from './components/exercise-view'
import { LessonQuiz } from './components/lesson-quiz'
const navBtnClass =
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
interface LessonPageProps {
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
export default function LessonPage({ params }: LessonPageProps) {
const { courseSlug, lessonSlug } = use(params)
const course = getCourse(courseSlug)
const [exerciseComplete, setExerciseComplete] = useState(false)
const [quizComplete, setQuizComplete] = useState(false)
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
if (prevLessonSlug !== lessonSlug) {
setPrevLessonSlug(lessonSlug)
setExerciseComplete(false)
setQuizComplete(false)
}
const allLessons = useMemo<Lesson[]>(
() => course?.modules.flatMap((m) => m.lessons) ?? [],
[course]
)
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
const lesson = allLessons[currentIndex]
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
const canAdvance =
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
? exerciseComplete && quizComplete
: lesson?.exerciseConfig
? exerciseComplete
: quizComplete)
const isUngatedLesson =
lesson?.lessonType === 'video' ||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
useEffect(() => {
if (isUngatedLesson && lesson) {
markLessonComplete(lesson.id)
}
}, [lesson?.id, isUngatedLesson])
if (!course || !lesson) {
return (
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
</div>
)
}
const hasVideo = Boolean(lesson.videoUrl)
const hasExercise = Boolean(lesson.exerciseConfig)
const hasQuiz = Boolean(lesson.quizConfig)
return (
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
<div className='flex items-center gap-3 text-[13px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/b&w/text/b&w.svg'
alt='Sim'
width={40}
height={14}
className='opacity-70 invert transition-opacity hover:opacity-100'
/>
</Link>
<span className='text-[#333]'>/</span>
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
Academy
</Link>
<span className='text-[#333]'>/</span>
<Link
href={`/academy/${courseSlug}`}
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
>
{course.title}
</Link>
<span className='text-[#333]'>/</span>
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
</div>
<div className='flex items-center gap-2'>
{prevLesson ? (
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Previous
</Link>
) : (
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Course
</Link>
)}
{nextLesson && (
<Link
href={`/academy/${courseSlug}/${nextLesson.slug}`}
onClick={(e) => {
if (!canAdvance) e.preventDefault()
}}
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
canAdvance
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
}`}
>
Next
<ChevronRight className='h-3.5 w-3.5' />
</Link>
)}
</div>
</header>
<div className='flex min-h-0 flex-1 overflow-hidden'>
{lesson.lessonType === 'video' && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
)}
</div>
</div>
)}
{lesson.lessonType === 'exercise' && hasExercise && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
/>
)}
{lesson.lessonType === 'quiz' && hasQuiz && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-2xl'>
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{lesson.lessonType === 'mixed' && (
<>
{hasExercise && (!exerciseComplete || !hasQuiz) && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
description={!hasQuiz ? lesson.description : undefined}
/>
)}
{hasExercise && exerciseComplete && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && !hasQuiz && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
{lesson.description}
</p>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
interface LessonVideoProps {
url: string
title: string
}
export function LessonVideo({ url, title }: LessonVideoProps) {
const embedUrl = resolveEmbedUrl(url)
if (!embedUrl) {
return (
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
Video unavailable
</div>
)
}
return (
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
<iframe
src={embedUrl}
title={title}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
className='h-full w-full border-0'
/>
</div>
)
}
function resolveEmbedUrl(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.hostname === 'youtu.be') {
return `https://www.youtube.com/embed${parsed.pathname}`
}
if (parsed.hostname.includes('youtube.com')) {
// Shorts: youtube.com/shorts/VIDEO_ID
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
const v = parsed.searchParams.get('v')
if (v) return `https://www.youtube.com/embed/${v}`
}
if (parsed.hostname === 'vimeo.com') {
const id = parsed.pathname.replace(/^\//, '')
if (id) return `https://player.vimeo.com/video/${id}`
}
return null
} catch (_e: unknown) {
return null
}
}

View File

@@ -0,0 +1,443 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
import type {
ExerciseBlockState,
ExerciseDefinition,
ExerciseEdgeState,
ValidationResult,
} from '@/lib/academy/types'
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'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { LessonVideo } from './lesson-video'
import { ValidationChecklist } from './validation-checklist'
const logger = createLogger('SandboxCanvasProvider')
const SANDBOX_WORKSPACE_ID = 'sandbox'
interface SandboxCanvasProviderProps {
/** Unique ID for this exercise instance */
exerciseId: string
/** Full exercise configuration */
exerciseConfig: ExerciseDefinition
/**
* Called when all validation rules pass for the first time.
* Receives the current canvas state so the caller can persist it.
*/
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
videoUrl?: string
/** Optional description shown below the video (or below checklist if no video) */
description?: string
className?: string
}
/**
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
* Looks up each block type in the registry to construct proper sub-block and output maps.
*/
function buildWorkflowState(
initialBlocks: ExerciseBlockState[],
initialEdges: ExerciseEdgeState[]
): WorkflowState {
const blocks: Record<string, BlockState> = {}
for (const exerciseBlock of initialBlocks) {
const config = getBlock(exerciseBlock.type)
if (!config) {
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
continue
}
const subBlocks: Record<string, SubBlockState> = {}
for (const sb of config.subBlocks ?? []) {
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
subBlocks[sb.id] = {
id: sb.id,
type: sb.type,
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
}
}
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
triggerMode: false,
preferToolOutputs: true,
})
blocks[exerciseBlock.id] = {
id: exerciseBlock.id,
type: exerciseBlock.type,
name: config.name,
position: exerciseBlock.position,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 0,
locked: exerciseBlock.locked ?? false,
}
}
const edges: Edge[] = initialEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
type: 'default',
data: {},
}))
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
}
/**
* Reads the current canvas state from the workflow store and converts it to
* the exercise block/edge format used by the validation engine.
*/
function readCurrentCanvasState(workflowId: string): {
blocks: ExerciseBlockState[]
edges: ExerciseEdgeState[]
} {
const workflowStore = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
const blockValues = storedValues[block.id] ?? {}
const subBlocks: Record<string, unknown> = { ...blockValues }
return {
id: block.id,
type: block.type,
position: block.position,
subBlocks,
}
})
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
}))
return { blocks, edges }
}
/**
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
*
* - Pre-hydrates workflow stores directly (no API calls)
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
* - Displays a constrained block toolbar and live validation checklist
* - Supports mock execution to simulate workflow runs
*/
export function SandboxCanvasProvider({
exerciseId,
exerciseConfig,
onComplete,
videoUrl,
description,
className,
}: SandboxCanvasProviderProps) {
const [isReady, setIsReady] = useState(false)
const [validationResult, setValidationResult] = useState<ValidationResult>({
passed: false,
results: [],
})
const [hintIndex, setHintIndex] = useState(-1)
const completedRef = useRef(false)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const isMockRunningRef = useRef(false)
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
// Stable exercise ID — used as the workflow ID in the stores
const workflowId = `sandbox-${exerciseId}`
const runValidation = useCallback(() => {
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult((prev) => {
if (
prev.passed === result.passed &&
prev.results.length === result.results.length &&
prev.results.every((r, i) => r.passed === result.results[i].passed)
) {
return prev
}
return result
})
if (result.passed && !completedRef.current) {
completedRef.current = true
onCompleteRef.current?.(blocks, edges)
}
}, [workflowId, exerciseConfig.validationRules])
useEffect(() => {
completedRef.current = false
setHintIndex(-1)
const workflowState = buildWorkflowState(
exerciseConfig.initialBlocks ?? [],
exerciseConfig.initialEdges ?? []
)
const syntheticMetadata: WorkflowMetadata = {
id: workflowId,
name: 'Exercise',
lastModified: new Date(),
createdAt: new Date(),
color: '#3972F6',
workspaceId: SANDBOX_WORKSPACE_ID,
sortOrder: 0,
isSandbox: true,
}
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
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',
workspaceId: SANDBOX_WORKSPACE_ID,
workflowId,
requestId: null,
error: null,
},
})
logger.info('Sandbox stores hydrated', { workflowId })
setIsReady(true)
// Coalesce rapid store updates so validation runs at most once per animation frame.
let rafId: number | null = null
const scheduleValidation = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
runValidation()
})
}
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
// and returns immediately (no API call). Detect that signal here and run mock execution.
const unsubExecution = useExecutionStore.subscribe((state) => {
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
if (isExec && !isMockRunningRef.current) {
void handleMockRunRef.current()
}
})
runValidation()
return () => {
if (rafId !== null) cancelAnimationFrame(rafId)
unsubWorkflow()
unsubSubBlock()
unsubExecution()
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
return { workflowValues: rest }
})
}
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
const handleMockRun = useCallback(async () => {
if (isMockRunningRef.current) return
isMockRunningRef.current = true
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult(result)
if (!result.passed) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
if (plan.length === 0) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
const workflowBlocks = useWorkflowStore.getState().blocks
setIsExecuting(workflowId, true)
clearWorkflowConsole(workflowId)
useTerminalConsoleStore.setState({ isOpen: true })
try {
for (let i = 0; i < plan.length; i++) {
const step = plan[i]
setActiveBlocks(workflowId, new Set([step.blockId]))
await new Promise((resolve) => setTimeout(resolve, step.delay))
addConsole({
workflowId,
blockId: step.blockId,
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
blockType: step.blockType,
executionOrder: i,
output: step.output,
success: true,
durationMs: step.delay,
})
setActiveBlocks(workflowId, new Set())
}
} finally {
setIsExecuting(workflowId, false)
isMockRunningRef.current = false
}
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
handleMockRunRef.current = handleMockRun
const handleShowHint = useCallback(() => {
const hints = exerciseConfig.hints ?? []
if (hints.length === 0) return
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
}, [exerciseConfig.hints])
const handlePrevHint = useCallback(() => {
setHintIndex((i) => Math.max(i - 1, 0))
}, [])
if (!isReady) {
return (
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
</div>
)
}
const hints = exerciseConfig.hints ?? []
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
return (
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
<GlobalCommandsProvider>
<SandboxWorkspacePermissionsProvider>
<div className={cn('flex h-full w-full overflow-hidden', className)}>
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
{(videoUrl || description) && (
<div className='flex flex-col gap-2'>
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
{description && (
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
)}
<div className='border-[#1F1F1F] border-t' />
</div>
)}
{exerciseConfig.instructions && (
<p className='text-[#999] text-[11px] leading-relaxed'>
{exerciseConfig.instructions}
</p>
)}
<ValidationChecklist
results={validationResult.results}
allPassed={validationResult.passed}
/>
<div className='mt-auto flex flex-col gap-2'>
{currentHint && (
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
<div className='mb-1 flex items-center justify-between'>
<span className='font-[430] text-[#666]'>
Hint {hintIndex + 1}/{hints.length}
</span>
<div className='flex gap-1'>
<button
type='button'
onClick={handlePrevHint}
disabled={hintIndex === 0}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Previous hint'
>
</button>
<button
type='button'
onClick={handleShowHint}
disabled={hintIndex === hints.length - 1}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Next hint'
>
</button>
</div>
</div>
<span className='text-[#ECECEC]'>{currentHint}</span>
</div>
)}
{hints.length > 0 && hintIndex < 0 && (
<button
type='button'
onClick={handleShowHint}
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
>
Show hint
</button>
)}
</div>
</div>
<div className='relative flex-1 overflow-hidden'>
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
</div>
</div>
</SandboxWorkspacePermissionsProvider>
</GlobalCommandsProvider>
</SandboxBlockConstraintsContext.Provider>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import type { ValidationRuleResult } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface ValidationChecklistProps {
results: ValidationRuleResult[]
allPassed: boolean
}
/**
* Checklist showing exercise validation rules and their current pass/fail state.
* Rendered inside the exercise sidebar, not as a canvas overlay.
*/
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
if (results.length === 0) return null
return (
<div>
<div className='mb-2.5 flex items-center gap-1.5'>
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
{allPassed && (
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
Complete
</span>
)}
</div>
<ul className='space-y-1.5'>
{results.map((result, i) => (
<li key={i} className='flex items-start gap-2'>
{result.passed ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
)}
<span
className={cn(
'text-[11px] leading-tight',
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
)}
>
{result.message}
</span>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type React from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
// TODO: Remove notFound() call to make academy pages public once content is ready
const ACADEMY_ENABLED = false
export const metadata: Metadata = {
title: {
absolute: 'Sim Academy',
template: '%s | Sim Academy',
},
description:
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Sim Academy',
description: 'Become a certified Sim partner.',
type: 'website',
},
}
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
if (!ACADEMY_ENABLED) {
notFound()
}
return (
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
{children}
</div>
)
}

View File

@@ -0,0 +1,215 @@
import { db } from '@sim/db'
import { academyCertificate, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getCourseById } from '@/lib/academy/content'
import type { CertificateMetadata } from '@/lib/academy/types'
import { getSession } from '@/lib/auth'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
const logger = createLogger('AcademyCertificatesAPI')
const rateLimiter = new RateLimiter()
const CERT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 5,
refillRate: 1,
refillIntervalMs: 60 * 60_000, // 1 per hour refill
}
const IssueCertificateSchema = z.object({
courseId: z.string(),
completedLessonIds: z.array(z.string()),
})
/**
* POST /api/academy/certificates
* Issues a certificate for the given course after verifying all lessons are completed.
* Completion is client-attested: the client sends completed lesson IDs and the server
* validates them against the full lesson list for the course.
*/
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { allowed } = await rateLimiter.checkRateLimitDirect(
`academy:cert:${session.user.id}`,
CERT_RATE_LIMIT
)
if (!allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
const body = await req.json()
const parsed = IssueCertificateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const { courseId, completedLessonIds } = parsed.data
const course = getCourseById(courseId)
if (!course) {
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
}
// Verify all lessons in the course are reported as completed
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
const completedSet = new Set(completedLessonIds)
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
if (incomplete.length > 0) {
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
}
const [existing, learner] = await Promise.all([
db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
.then((rows) => rows[0] ?? null),
])
if (existing) {
if (existing.status === 'active') {
return NextResponse.json({ certificate: existing })
}
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
const certificateNumber = generateCertificateNumber()
const metadata: CertificateMetadata = {
recipientName: learner?.name ?? session.user.name ?? 'Partner',
courseTitle: course.title,
}
const [certificate] = await db
.insert(academyCertificate)
.values({
id: nanoid(),
userId: session.user.id,
courseId,
status: 'active',
certificateNumber,
metadata,
})
.onConflictDoNothing()
.returning()
if (!certificate) {
const [race] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
if (race?.status === 'active') {
return NextResponse.json({ certificate: race })
}
if (race) {
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
logger.info('Certificate issued', {
userId: session.user.id,
courseId,
certificateNumber,
})
return NextResponse.json({ certificate }, { status: 201 })
} catch (error) {
logger.error('Failed to issue certificate', { error })
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
}
/**
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
* Public endpoint for verifying a certificate by its number.
*
* GET /api/academy/certificates?courseId=...
* Authenticated endpoint for looking up the current user's certificate for a course.
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const certificateNumber = searchParams.get('certificateNumber')
const courseId = searchParams.get('courseId')
if (certificateNumber) {
const [certificate] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
if (!certificate) {
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
}
return NextResponse.json({ certificate })
}
if (courseId) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [certificate] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
return NextResponse.json({ certificate: certificate ?? null })
}
return NextResponse.json(
{ error: 'certificateNumber or courseId query parameter is required' },
{ status: 400 }
)
} catch (error) {
logger.error('Failed to verify certificate', { error })
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
}
}
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
function generateCertificateNumber(): string {
const year = new Date().getFullYear()
return `SIM-${year}-${nanoid(8).toUpperCase()}`
}

View File

@@ -15,12 +15,12 @@ const {
mockLimit,
mockUpdate,
mockSet,
mockDelete,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckChatAccess,
mockDeployWorkflow,
mockPerformChatUndeploy,
mockLogger,
} = vi.hoisted(() => {
const logger = {
@@ -40,12 +40,12 @@ const {
mockLimit: vi.fn(),
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockDelete: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckChatAccess: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatUndeploy: vi.fn(),
mockLogger: logger,
}
})
@@ -66,7 +66,6 @@ vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}))
vi.mock('@sim/db/schema', () => ({
@@ -88,6 +87,9 @@ vi.mock('@/app/api/chat/utils', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performChatUndeploy: mockPerformChatUndeploy,
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -106,7 +108,7 @@ describe('Chat Edit API Route', () => {
mockWhere.mockReturnValue({ limit: mockLimit })
mockUpdate.mockReturnValue({ set: mockSet })
mockSet.mockReturnValue({ where: mockWhere })
mockDelete.mockReturnValue({ where: mockWhere })
mockPerformChatUndeploy.mockResolvedValue({ success: true })
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -428,7 +430,11 @@ describe('Chat Edit API Route', () => {
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockDelete).toHaveBeenCalled()
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'user-id',
workspaceId: 'workspace-123',
})
const data = await response.json()
expect(data.message).toBe('Chat deployment deleted successfully')
})
@@ -451,7 +457,11 @@ describe('Chat Edit API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockDelete).toHaveBeenCalled()
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'admin-user-id',
workspaceId: 'workspace-123',
})
})
})
})

View File

@@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { performChatUndeploy } from '@/lib/workflows/orchestration'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -270,33 +271,25 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401)
}
const {
hasAccess,
chat: chatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
chatId,
session.user.id
)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
await db.delete(chat).where(eq(chat.id, chatId))
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
const result = await performChatUndeploy({
chatId,
userId: session.user.id,
workspaceId: chatWorkspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to delete chat', 500)
}
return createSuccessResponse({
message: 'Chat deployment deleted successfully',
})

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { auditMock, createEnvMock } from '@sim/testing'
import { createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,66 +12,51 @@ const {
mockFrom,
mockWhere,
mockLimit,
mockInsert,
mockValues,
mockReturning,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckWorkflowAccessForChatCreation,
mockDeployWorkflow,
mockPerformChatDeploy,
mockGetSession,
mockUuidV4,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockInsert: vi.fn(),
mockValues: vi.fn(),
mockReturning: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckWorkflowAccessForChatCreation: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatDeploy: vi.fn(),
mockGetSession: vi.fn(),
mockUuidV4: vi.fn(),
}))
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.mock('@sim/db/schema', () => ({
chat: { userId: 'userId', identifier: 'identifier' },
chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
}))
vi.mock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse,
createErrorResponse: mockCreateErrorResponse,
}))
vi.mock('@/lib/core/security/encryption', () => ({
encryptSecret: mockEncryptSecret,
}))
vi.mock('uuid', () => ({
v4: mockUuidV4,
}))
vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
vi.mock('@/lib/workflows/orchestration', () => ({
performChatDeploy: mockPerformChatDeploy,
}))
vi.mock('@/lib/auth', () => ({
@@ -94,10 +79,6 @@ describe('Chat API Route', () => {
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockUuidV4.mockReturnValue('test-uuid')
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -113,12 +94,10 @@ describe('Chat API Route', () => {
})
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockDeployWorkflow.mockResolvedValue({
mockPerformChatDeploy.mockResolvedValue({
success: true,
version: 1,
deployedAt: new Date(),
chatId: 'test-uuid',
chatUrl: 'http://localhost:3000/chat/test-chat',
})
})
@@ -277,7 +256,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -287,6 +265,13 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
identifier: 'test-chat',
})
)
})
it('should allow chat deployment when user has workspace admin permission', async () => {
@@ -309,7 +294,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -319,6 +303,12 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
workspaceId: 'workspace-123',
})
)
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
@@ -383,7 +373,7 @@ describe('Chat API Route', () => {
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should auto-deploy workflow if not already deployed', async () => {
it('should call performChatDeploy for undeployed workflow', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
@@ -403,7 +393,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -412,10 +401,12 @@ describe('Chat API Route', () => {
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockDeployWorkflow).toHaveBeenCalledWith({
workflowId: 'workflow-123',
deployedBy: 'user-id',
})
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
})
)
})
})
})

View File

@@ -3,14 +3,9 @@ import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { performChatDeploy } from '@/lib/workflows/orchestration'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -109,7 +104,6 @@ export async function POST(request: NextRequest) {
)
}
// Check identifier availability and workflow access in parallel
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db
.select()
@@ -127,121 +121,27 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Workflow not found or access denied', 404)
}
// Always deploy/redeploy the workflow to ensure latest version
const result = await deployWorkflow({
workflowId,
deployedBy: session.user.id,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
}
logger.info(
`${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})`
)
// Encrypt password if provided
let encryptedPassword = null
if (authType === 'password' && password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
}
// Create the chat deployment
const id = uuidv4()
// Log the values we're inserting
logger.info('Creating chat deployment with values:', {
workflowId,
identifier,
title,
authType,
hasPassword: !!encryptedPassword,
emailCount: allowedEmails?.length || 0,
outputConfigsCount: outputConfigs.length,
})
// Merge customizations with the additional fields
const mergedCustomizations = {
...(customizations || {}),
primaryColor: customizations?.primaryColor || 'var(--brand-hover)',
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?',
}
await db.insert(chat).values({
id,
const result = await performChatDeploy({
workflowId,
userId: session.user.id,
identifier,
title,
description: description || null,
customizations: mergedCustomizations,
isActive: true,
description,
customizations,
authType,
password: encryptedPassword,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
password,
allowedEmails,
outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),
workspaceId: workflowRecord.workspaceId,
})
// Return successful response with chat URL
// Generate chat URL using path-based routing instead of subdomains
const baseUrl = getBaseUrl()
let chatUrl: string
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) {
host = host.substring(4)
}
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
} catch (error) {
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
baseUrl,
error: error instanceof Error ? error.message : 'Unknown error',
})
// Fallback based on environment
if (isDev) {
chatUrl = `http://localhost:3000/chat/${identifier}`
} else {
chatUrl = `https://sim.ai/chat/${identifier}`
}
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
}
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.chatDeployed({
chatId: id,
workflowId,
authType,
hasOutputConfigs: outputConfigs.length > 0,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
resourceId: id,
resourceName: title,
description: `Deployed chat "${title}"`,
metadata: { workflowId, identifier, authType },
request,
})
return createSuccessResponse({
id,
chatUrl,
id: result.chatId,
chatUrl: result.chatUrl,
message: 'Chat deployment created successfully',
})
} catch (validationError) {

View File

@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
@@ -14,7 +15,6 @@ import {
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
@@ -183,36 +183,31 @@ export async function POST(req: NextRequest) {
const wf = await getWorkflowById(workflowId)
resolvedWorkspaceId = wf?.workspaceId ?? undefined
} catch {
logger.warn(
appendCopilotLogContext('Failed to resolve workspaceId from workflow', {
requestId: tracker.requestId,
messageId: userMessageId,
})
)
logger
.withMetadata({ requestId: tracker.requestId, messageId: userMessageId })
.warn('Failed to resolve workspaceId from workflow')
}
const userMessageIdToUse = userMessageId || crypto.randomUUID()
const reqLogger = logger.withMetadata({
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
try {
logger.error(
appendCopilotLogContext('Received chat POST', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
? normalizedContexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
}
)
reqLogger.info('Received chat POST', {
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
? normalizedContexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
})
} catch {}
let currentChat: any = null
@@ -250,40 +245,22 @@ export async function POST(req: NextRequest) {
actualChatId
)
agentContexts = processed
logger.error(
appendCopilotLogContext('Contexts processed for request', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
}
)
reqLogger.info('Contexts processed for request', {
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
})
if (
Array.isArray(normalizedContexts) &&
normalizedContexts.length > 0 &&
agentContexts.length === 0
) {
logger.warn(
appendCopilotLogContext(
'Contexts provided but none processed. Check executionId for logs contexts.',
{
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}
)
reqLogger.warn(
'Contexts provided but none processed. Check executionId for logs contexts.'
)
}
} catch (e) {
logger.error(
appendCopilotLogContext('Failed to process contexts', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
e
)
reqLogger.error('Failed to process contexts', e)
}
}
@@ -312,13 +289,7 @@ export async function POST(req: NextRequest) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
appendCopilotLogContext('Failed to resolve resource attachment', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
result.reason
)
reqLogger.error('Failed to resolve resource attachment', result.reason)
}
}
}
@@ -357,26 +328,20 @@ export async function POST(req: NextRequest) {
)
try {
logger.error(
appendCopilotLogContext('About to call Sim Agent', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
hasTools: Array.isArray(requestPayload.tools),
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
hasBaseTools: Array.isArray(requestPayload.baseTools),
baseToolCount: Array.isArray(requestPayload.baseTools)
? requestPayload.baseTools.length
: 0,
hasCredentials: !!requestPayload.credentials,
}
)
reqLogger.info('About to call Sim Agent', {
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
hasTools: Array.isArray(requestPayload.tools),
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
hasBaseTools: Array.isArray(requestPayload.baseTools),
baseToolCount: Array.isArray(requestPayload.baseTools)
? requestPayload.baseTools.length
: 0,
hasCredentials: !!requestPayload.credentials,
})
} catch {}
if (stream && actualChatId) {
@@ -520,16 +485,10 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId))
}
} catch (error) {
logger.error(
appendCopilotLogContext('Failed to persist chat messages', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
}
)
reqLogger.error('Failed to persist chat messages', {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
},
},
@@ -539,10 +498,26 @@ export async function POST(req: NextRequest) {
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
const nsExecutionId = crypto.randomUUID()
const nsRunId = crypto.randomUUID()
if (actualChatId) {
await createRunSegment({
id: nsRunId,
executionId: nsExecutionId,
chatId: actualChatId,
userId: authenticatedUserId,
workflowId,
streamId: userMessageIdToUse,
}).catch(() => {})
}
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
executionId: nsExecutionId,
runId: nsRunId,
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
@@ -555,19 +530,13 @@ export async function POST(req: NextRequest) {
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
}
logger.error(
appendCopilotLogContext('Non-streaming response from orchestrator', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
hasContent: !!responseData.content,
contentLength: responseData.content?.length || 0,
model: responseData.model,
provider: responseData.provider,
toolCallsCount: responseData.toolCalls?.length || 0,
}
)
reqLogger.info('Non-streaming response from orchestrator', {
hasContent: !!responseData.content,
contentLength: responseData.content?.length || 0,
model: responseData.model,
provider: responseData.provider,
toolCallsCount: responseData.toolCalls?.length || 0,
})
// Save messages if we have a chat
if (currentChat && responseData.content) {
@@ -600,12 +569,7 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
logger.error(
appendCopilotLogContext('Starting title generation for non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
)
reqLogger.info('Starting title generation for non-streaming response')
requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse })
.then(async (title) => {
if (title) {
@@ -616,22 +580,11 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
logger.error(
appendCopilotLogContext(`Generated and saved title: ${title}`, {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
)
reqLogger.info(`Generated and saved title: ${title}`)
}
})
.catch((error) => {
logger.error(
appendCopilotLogContext('Title generation failed', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
error
)
reqLogger.error('Title generation failed', error)
})
}
@@ -645,17 +598,11 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId!))
}
logger.error(
appendCopilotLogContext('Returning non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
duration: tracker.getDuration(),
chatId: actualChatId,
responseLength: responseData.content?.length || 0,
}
)
reqLogger.info('Returning non-streaming response', {
duration: tracker.getDuration(),
chatId: actualChatId,
responseLength: responseData.content?.length || 0,
})
return NextResponse.json({
success: true,
@@ -679,33 +626,25 @@ export async function POST(req: NextRequest) {
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {
logger.error(
appendCopilotLogContext('Validation error', {
requestId: tracker.requestId,
messageId: pendingChatStreamID ?? undefined,
}),
{
logger
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
.error('Validation error', {
duration,
errors: error.errors,
}
)
})
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(
appendCopilotLogContext('Error handling copilot chat', {
requestId: tracker.requestId,
messageId: pendingChatStreamID ?? undefined,
}),
{
logger
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
.error('Error handling copilot chat', {
duration,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
}
)
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
@@ -750,16 +689,13 @@ export async function GET(req: NextRequest) {
status: meta?.status || 'unknown',
}
} catch (err) {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for chat', {
messageId: chat.conversationId || undefined,
}),
{
logger
.withMetadata({ messageId: chat.conversationId || undefined })
.warn('Failed to read stream snapshot for chat', {
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
}
)
})
}
}
@@ -778,11 +714,9 @@ export async function GET(req: NextRequest) {
...(streamSnapshot ? { streamSnapshot } : {}),
}
logger.error(
appendCopilotLogContext(`Retrieved chat ${chatId}`, {
messageId: chat.conversationId || undefined,
})
)
logger
.withMetadata({ messageId: chat.conversationId || undefined })
.info(`Retrieved chat ${chatId}`)
return NextResponse.json({ success: true, chat: transformedChat })
}

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
getStreamMeta,
readStreamEvents,
@@ -36,24 +35,21 @@ export async function GET(request: NextRequest) {
const toParam = url.searchParams.get('to')
const toEventId = toParam ? Number(toParam) : undefined
logger.error(
appendCopilotLogContext('[Resume] Received resume request', {
messageId: streamId || undefined,
}),
{
streamId: streamId || undefined,
fromEventId,
toEventId,
batchMode,
}
)
const reqLogger = logger.withMetadata({ messageId: streamId || undefined })
reqLogger.info('[Resume] Received resume request', {
streamId: streamId || undefined,
fromEventId,
toEventId,
batchMode,
})
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
const meta = (await getStreamMeta(streamId)) as StreamMeta | null
logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
reqLogger.info('[Resume] Stream lookup', {
streamId,
fromEventId,
toEventId,
@@ -72,7 +68,7 @@ export async function GET(request: NextRequest) {
if (batchMode) {
const events = await readStreamEvents(streamId, fromEventId)
const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events
logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
reqLogger.info('[Resume] Batch response', {
streamId,
fromEventId,
toEventId,
@@ -124,14 +120,11 @@ export async function GET(request: NextRequest) {
const flushEvents = async () => {
const events = await readStreamEvents(streamId, lastEventId)
if (events.length > 0) {
logger.error(
appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }),
{
streamId,
fromEventId: lastEventId,
eventCount: events.length,
}
)
reqLogger.info('[Resume] Flushing events', {
streamId,
fromEventId: lastEventId,
eventCount: events.length,
})
}
for (const entry of events) {
lastEventId = entry.eventId
@@ -178,7 +171,7 @@ export async function GET(request: NextRequest) {
}
} catch (error) {
if (!controllerClosed && !request.signal.aborted) {
logger.warn(appendCopilotLogContext('Stream replay failed', { messageId: streamId }), {
reqLogger.warn('Stream replay failed', {
streamId,
error: error instanceof Error ? error.message : String(error),
})

View File

@@ -12,6 +12,7 @@ const {
mockReturning,
mockSelect,
mockFrom,
mockWhere,
mockAuthenticate,
mockCreateUnauthorizedResponse,
mockCreateBadRequestResponse,
@@ -23,6 +24,7 @@ const {
mockReturning: vi.fn(),
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockAuthenticate: vi.fn(),
mockCreateUnauthorizedResponse: vi.fn(),
mockCreateBadRequestResponse: vi.fn(),
@@ -81,7 +83,8 @@ describe('Copilot Feedback API Route', () => {
mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockResolvedValue([])
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockResolvedValue([])
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockResolvedValue([])
mockCreateRequestTracker.mockReturnValue({
requestId: 'test-request-id',
@@ -386,7 +389,7 @@ edges:
isAuthenticated: true,
})
mockFrom.mockResolvedValueOnce([])
mockWhere.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -397,7 +400,7 @@ edges:
expect(responseData.feedback).toEqual([])
})
it('should return all feedback records', async () => {
it('should only return feedback records for the authenticated user', async () => {
mockAuthenticate.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
@@ -415,19 +418,8 @@ edges:
workflowYaml: null,
createdAt: new Date('2024-01-01'),
},
{
feedbackId: 'feedback-2',
userId: 'user-456',
chatId: 'chat-2',
userQuery: 'Query 2',
agentResponse: 'Response 2',
isPositive: false,
feedback: 'Not helpful',
workflowYaml: 'yaml: content',
createdAt: new Date('2024-01-02'),
},
]
mockFrom.mockResolvedValueOnce(mockFeedback)
mockWhere.mockResolvedValueOnce(mockFeedback)
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -435,9 +427,14 @@ edges:
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toHaveLength(2)
expect(responseData.feedback).toHaveLength(1)
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
expect(responseData.feedback[0].userId).toBe('user-123')
// Verify the where clause was called with the authenticated user's ID
const { eq } = await import('drizzle-orm')
expect(mockWhere).toHaveBeenCalled()
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
})
it('should handle database errors gracefully', async () => {
@@ -446,7 +443,7 @@ edges:
isAuthenticated: true,
})
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -462,7 +459,7 @@ edges:
isAuthenticated: true,
})
mockFrom.mockResolvedValueOnce([])
mockWhere.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { copilotFeedback } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
@@ -109,7 +110,7 @@ export async function POST(req: NextRequest) {
/**
* GET /api/copilot/feedback
* Get all feedback records (for analytics)
* Get feedback records for the authenticated user
*/
export async function GET(req: NextRequest) {
const tracker = createRequestTracker()
@@ -123,7 +124,7 @@ export async function GET(req: NextRequest) {
return createUnauthorizedResponse()
}
// Get all feedback records
// Get feedback records for the authenticated user only
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
@@ -137,6 +138,7 @@ export async function GET(req: NextRequest) {
createdAt: copilotFeedback.createdAt,
})
.from(copilotFeedback)
.where(eq(copilotFeedback.userId, authenticatedUserId))
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotTrainingExamplesAPI')
@@ -16,6 +20,11 @@ const TrainingExampleSchema = z.object({
})
export async function POST(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotTrainingAPI')
@@ -22,6 +26,11 @@ const TrainingDataSchema = z.object({
})
export async function POST(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
try {
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {

View File

@@ -100,7 +100,7 @@ const emailTemplates = {
trigger: 'api',
duration: '2.3s',
cost: '$0.0042',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
}),
'workflow-notification-error': () =>
renderWorkflowNotificationEmail({
@@ -109,7 +109,7 @@ const emailTemplates = {
trigger: 'webhook',
duration: '1.1s',
cost: '$0.0021',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
}),
'workflow-notification-alert': () =>
renderWorkflowNotificationEmail({
@@ -118,7 +118,7 @@ const emailTemplates = {
trigger: 'schedule',
duration: '45.2s',
cost: '$0.0156',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
alertReason: '3 consecutive failures detected',
}),
'workflow-notification-full': () =>
@@ -128,7 +128,7 @@ const emailTemplates = {
trigger: 'api',
duration: '12.5s',
cost: '$0.0234',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
finalOutput: { processed: 150, skipped: 3, status: 'completed' },
rateLimits: {
sync: { requestsPerMinute: 60, remaining: 45 },

View File

@@ -6,7 +6,14 @@
import { auditMock, createMockRequest, type MockUser } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = vi.hoisted(() => {
const {
mockGetSession,
mockGetUserEntityPermissions,
mockLogger,
mockDbRef,
mockPerformDeleteFolder,
mockCheckForCircularReference,
} = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
@@ -21,6 +28,8 @@ const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } =
mockGetUserEntityPermissions: vi.fn(),
mockLogger: logger,
mockDbRef: { current: null as any },
mockPerformDeleteFolder: vi.fn(),
mockCheckForCircularReference: vi.fn(),
}
})
@@ -39,6 +48,12 @@ vi.mock('@sim/db', () => ({
return mockDbRef.current
},
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performDeleteFolder: mockPerformDeleteFolder,
}))
vi.mock('@/lib/workflows/utils', () => ({
checkForCircularReference: mockCheckForCircularReference,
}))
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
@@ -144,6 +159,11 @@ describe('Individual Folder API Route', () => {
mockGetUserEntityPermissions.mockResolvedValue('admin')
mockDbRef.current = createFolderDbMock()
mockPerformDeleteFolder.mockResolvedValue({
success: true,
deletedItems: { folders: 1, workflows: 0 },
})
mockCheckForCircularReference.mockResolvedValue(false)
})
describe('PUT /api/folders/[id]', () => {
@@ -369,13 +389,17 @@ describe('Individual Folder API Route', () => {
it('should prevent circular references when updating parent', async () => {
mockAuthenticatedUser()
const circularCheckResults = [{ parentId: 'folder-2' }, { parentId: 'folder-3' }]
mockDbRef.current = createFolderDbMock({
folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' },
circularCheckResults,
folderLookupResult: {
id: 'folder-3',
parentId: null,
name: 'Folder 3',
workspaceId: 'workspace-123',
},
})
mockCheckForCircularReference.mockResolvedValue(true)
const req = createMockRequest('PUT', {
name: 'Updated Folder 3',
parentId: 'folder-1',
@@ -388,6 +412,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
expect(mockCheckForCircularReference).toHaveBeenCalledWith('folder-3', 'folder-1')
})
})
@@ -409,6 +434,12 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('deletedItems')
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
folderId: 'folder-1',
workspaceId: 'workspace-123',
userId: TEST_USER.id,
folderName: 'Test Folder',
})
})
it('should return 401 for unauthenticated delete requests', async () => {
@@ -472,6 +503,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
})
it('should handle database errors during deletion', async () => {

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import { performDeleteFolder } from '@/lib/workflows/orchestration'
import { checkForCircularReference } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersIDAPI')
@@ -130,7 +130,6 @@ export async function DELETE(
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
// Check if user has admin permissions for the workspace (admin-only for deletions)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -144,170 +143,25 @@ export async function DELETE(
)
}
// Check if deleting this folder would delete the last workflow(s) in the workspace
const workflowsInFolder = await countWorkflowsInFolderRecursively(
id,
existingFolder.workspaceId
)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, existingFolder.workspaceId), isNull(workflow.archivedAt)))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return NextResponse.json(
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
{ status: 400 }
)
}
// Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
logger.info('Deleted folder and all contents:', {
id,
deletionStats,
})
recordAudit({
const result = await performDeleteFolder({
folderId: id,
workspaceId: existingFolder.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: id,
resourceName: existingFolder.name,
description: `Deleted folder "${existingFolder.name}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
request,
userId: session.user.id,
folderName: existingFolder.name,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
return NextResponse.json({
success: true,
deletedItems: deletionStats,
deletedItems: result.deletedItems,
})
} catch (error) {
logger.error('Error deleting folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Helper function to recursively delete a folder and all its contents
async function deleteFolderRecursively(
folderId: string,
workspaceId: string
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
// Get all child folders first (workspace-scoped, not user-scoped)
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
// Recursively delete child folders
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
// Delete all workflows in this folder (workspace-scoped, not user-scoped)
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolder.length > 0) {
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}` }
)
stats.workflows += workflowsInFolder.length
}
// Delete this folder
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
}
/**
* Counts the number of workflows in a folder and all its subfolders recursively.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
// Helper function to check for circular references
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
return true // Circular reference detected
}
if (currentParentId === folderId) {
return true // Would create a cycle
}
visited.add(currentParentId)
// Get the parent of the current parent
const parent: { parentId: string | null } | undefined = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, currentParentId))
.then((rows) => rows[0])
currentParentId = parent?.parentId || null
}
return false
}

View File

@@ -26,6 +26,14 @@ vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: mockExecuteInE2B,
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isHosted: false,
isE2bEnabled: false,
isProd: false,
isDev: false,
isTest: true,
}))
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { POST } from '@/app/api/function/execute/route'

View File

@@ -0,0 +1,160 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckHybridAuth,
mockGetDispatchJobRecord,
mockGetJobQueue,
mockVerifyWorkflowAccess,
mockGetWorkflowById,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockGetDispatchJobRecord: vi.fn(),
mockGetJobQueue: vi.fn(),
mockVerifyWorkflowAccess: vi.fn(),
mockGetWorkflowById: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
vi.mock('@/lib/core/async-jobs', () => ({
JOB_STATUS: {
PENDING: 'pending',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed',
},
getJobQueue: mockGetJobQueue,
}))
vi.mock('@/lib/core/workspace-dispatch/store', () => ({
getDispatchJobRecord: mockGetDispatchJobRecord,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('request-1'),
}))
vi.mock('@/socket/middleware/permissions', () => ({
verifyWorkflowAccess: mockVerifyWorkflowAccess,
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: mockGetWorkflowById,
}))
import { GET } from './route'
function createMockRequest(): NextRequest {
return {
headers: {
get: () => null,
},
} as NextRequest
}
describe('GET /api/jobs/[jobId]', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: 'user-1',
apiKeyType: undefined,
workspaceId: undefined,
})
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true })
mockGetWorkflowById.mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
})
mockGetJobQueue.mockResolvedValue({
getJob: vi.fn().mockResolvedValue(null),
})
})
it('returns dispatcher-aware waiting status with metadata', async () => {
mockGetDispatchJobRecord.mockResolvedValue({
id: 'dispatch-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {
workflowId: 'workflow-1',
},
priority: 10,
status: 'waiting',
createdAt: 1000,
admittedAt: 2000,
})
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'dispatch-1' }),
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('waiting')
expect(body.metadata.queueName).toBe('workflow-execution')
expect(body.metadata.lane).toBe('runtime')
expect(body.metadata.workspaceId).toBe('workspace-1')
})
it('returns completed output from dispatch state', async () => {
mockGetDispatchJobRecord.mockResolvedValue({
id: 'dispatch-2',
workspaceId: 'workspace-1',
lane: 'interactive',
queueName: 'workflow-execution',
bullmqJobName: 'direct-workflow-execution',
bullmqPayload: {},
metadata: {
workflowId: 'workflow-1',
},
priority: 1,
status: 'completed',
createdAt: 1000,
startedAt: 2000,
completedAt: 7000,
output: { success: true },
})
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'dispatch-2' }),
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('completed')
expect(body.output).toEqual({ success: true })
expect(body.metadata.duration).toBe(5000)
})
it('returns 404 when neither dispatch nor BullMQ job exists', async () => {
mockGetDispatchJobRecord.mockResolvedValue(null)
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'missing-job' }),
})
expect(response.status).toBe(404)
})
})

View File

@@ -1,8 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, JOB_STATUS } from '@/lib/core/async-jobs'
import { getJobQueue } from '@/lib/core/async-jobs'
import { generateRequestId } from '@/lib/core/utils/request'
import { presentDispatchOrJobStatus } from '@/lib/core/workspace-dispatch/status'
import { getDispatchJobRecord } from '@/lib/core/workspace-dispatch/store'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('TaskStatusAPI')
@@ -23,68 +25,54 @@ export async function GET(
const authenticatedUserId = authResult.userId
const dispatchJob = await getDispatchJobRecord(taskId)
const jobQueue = await getJobQueue()
const job = await jobQueue.getJob(taskId)
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
if (!job) {
if (!job && !dispatchJob) {
return createErrorResponse('Task not found', 404)
}
if (job.metadata?.workflowId) {
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
if (metadataToCheck?.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
const accessCheck = await verifyWorkflowAccess(
authenticatedUserId,
job.metadata.workflowId as string
metadataToCheck.workflowId as string
)
if (!accessCheck.hasAccess) {
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
return createErrorResponse('Access denied', 403)
}
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const workflow = await getWorkflowById(job.metadata.workflowId as string)
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
return createErrorResponse('API key is not authorized for this workspace', 403)
}
}
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
return createErrorResponse('Access denied', 403)
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
return createErrorResponse('Access denied', 403)
}
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
const presented = presentDispatchOrJobStatus(dispatchJob, job)
const response: any = {
success: true,
taskId,
status: mappedStatus,
metadata: {
startedAt: job.startedAt,
},
status: presented.status,
metadata: presented.metadata,
}
if (job.status === JOB_STATUS.COMPLETED) {
response.output = job.output
response.metadata.completedAt = job.completedAt
if (job.startedAt && job.completedAt) {
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
}
}
if (job.status === JOB_STATUS.FAILED) {
response.error = job.error
response.metadata.completedAt = job.completedAt
if (job.startedAt && job.completedAt) {
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
}
}
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
response.estimatedDuration = 300000
if (presented.output !== undefined) response.output = presented.output
if (presented.error !== undefined) response.error = presented.error
if (presented.estimatedDuration !== undefined) {
response.estimatedDuration = presented.estimatedDuration
}
return NextResponse.json(response)

View File

@@ -237,7 +237,7 @@ describe('Knowledge Connector By ID API Route', () => {
.mockReturnValueOnce(mockDbChain)
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
.mockReturnValueOnce(mockDbChain)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('DELETE')

View File

@@ -292,7 +292,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const connectorDocuments = await db.transaction(async (tx) => {
const { searchParams } = new URL(request.url)
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
const docs = await tx
@@ -306,10 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
)
)
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
if (deleteDocuments) {
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
}
}
const deletedConnectors = await tx
@@ -328,16 +333,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
throw new Error('Connector not found')
}
return docs
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
})
await deleteDocumentStorageFiles(connectorDocuments, requestId)
if (deleteDocuments) {
await Promise.all([
deletedDocs.length > 0
? deleteDocumentStorageFiles(deletedDocs, requestId)
: Promise.resolve(),
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
}),
])
}
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
})
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
logger.info(
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
@@ -349,7 +361,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
resourceId: connectorId,
resourceName: existingConnector[0].connectorType,
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
metadata: {
knowledgeBaseId,
documentsDeleted: deleteDocuments ? docCount : 0,
documentsKept: deleteDocuments ? 0 : docCount,
},
request,
})

View File

@@ -15,6 +15,8 @@ const GetChunksQuerySchema = z.object({
enabled: z.enum(['true', 'false', 'all']).optional().default('all'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
offset: z.coerce.number().min(0).optional().default(0),
sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'),
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
})
const CreateChunkSchema = z.object({
@@ -88,6 +90,8 @@ export async function GET(
enabled: searchParams.get('enabled') || undefined,
limit: searchParams.get('limit') || undefined,
offset: searchParams.get('offset') || undefined,
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: searchParams.get('sortOrder') || undefined,
})
const result = await queryChunks(documentId, queryParams, requestId)

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

@@ -18,6 +18,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
@@ -727,10 +728,25 @@ async function handleBuildToolCall(
chatId,
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
const messageId = requestPayload.messageId as string
await createRunSegment({
id: runId,
executionId,
chatId,
userId,
workflowId: resolved.workflowId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workflowId: resolved.workflowId,
chatId,
executionId,
runId,
goRoute: '/api/mcp',
autoExecuteTools: true,
timeout: ORCHESTRATION_TIMEOUT_MS,

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

@@ -12,7 +12,6 @@ import {
createSSEStream,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
@@ -112,27 +111,22 @@ export async function POST(req: NextRequest) {
const userMessageId = providedMessageId || crypto.randomUUID()
userMessageIdForLogs = userMessageId
const reqLogger = logger.withMetadata({
requestId: tracker.requestId,
messageId: userMessageId,
})
logger.error(
appendCopilotLogContext('Received mothership chat start request', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
{
workspaceId,
chatId,
createNewChat,
hasContexts: Array.isArray(contexts) && contexts.length > 0,
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
hasResourceAttachments:
Array.isArray(resourceAttachments) && resourceAttachments.length > 0,
resourceAttachmentCount: Array.isArray(resourceAttachments)
? resourceAttachments.length
: 0,
hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0,
}
)
reqLogger.info('Received mothership chat start request', {
workspaceId,
chatId,
createNewChat,
hasContexts: Array.isArray(contexts) && contexts.length > 0,
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
hasResourceAttachments: Array.isArray(resourceAttachments) && resourceAttachments.length > 0,
resourceAttachmentCount: Array.isArray(resourceAttachments) ? resourceAttachments.length : 0,
hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0,
})
try {
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
@@ -174,13 +168,7 @@ export async function POST(req: NextRequest) {
actualChatId
)
} catch (e) {
logger.error(
appendCopilotLogContext('Failed to process contexts', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
e
)
reqLogger.error('Failed to process contexts', e)
}
}
@@ -205,13 +193,7 @@ export async function POST(req: NextRequest) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
appendCopilotLogContext('Failed to resolve resource attachment', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
result.reason
)
reqLogger.error('Failed to resolve resource attachment', result.reason)
}
}
}
@@ -399,16 +381,10 @@ export async function POST(req: NextRequest) {
})
}
} catch (error) {
logger.error(
appendCopilotLogContext('Failed to persist chat messages', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
{
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
}
)
reqLogger.error('Failed to persist chat messages', {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
},
},
@@ -423,15 +399,11 @@ export async function POST(req: NextRequest) {
)
}
logger.error(
appendCopilotLogContext('Error handling mothership chat', {
requestId: tracker.requestId,
messageId: userMessageIdForLogs,
}),
{
logger
.withMetadata({ requestId: tracker.requestId, messageId: userMessageIdForLogs })
.error('Error handling mothership chat', {
error: error instanceof Error ? error.message : 'Unknown error',
}
)
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },

View File

@@ -5,6 +5,7 @@ import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { releasePendingChatStream } from '@/lib/copilot/chat-streaming'
import { taskPubSub } from '@/lib/copilot/task-events'
const logger = createLogger('MothershipChatStopAPI')
@@ -58,6 +59,8 @@ export async function POST(req: NextRequest) {
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())
await releasePendingChatStream(chatId, streamId)
const setClause: Record<string, unknown> = {
conversationId: null,
updatedAt: new Date(),

View File

@@ -5,7 +5,6 @@ import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
@@ -63,16 +62,13 @@ export async function GET(
status: meta?.status || 'unknown',
}
} catch (error) {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
messageId: chat.conversationId || undefined,
}),
{
logger
.withMetadata({ messageId: chat.conversationId || undefined })
.warn('Failed to read stream snapshot for mothership chat', {
chatId,
conversationId: chat.conversationId,
error: error instanceof Error ? error.message : String(error),
}
)
})
}
}

View File

@@ -2,8 +2,8 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
import {
@@ -52,6 +52,7 @@ export async function POST(req: NextRequest) {
const effectiveChatId = chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
const reqLogger = logger.withMetadata({ messageId })
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId),
@@ -71,17 +72,31 @@ export async function POST(req: NextRequest) {
...(userPermission ? { userPermission } : {}),
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
await createRunSegment({
id: runId,
executionId,
chatId: effectiveChatId,
userId,
workspaceId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workspaceId,
chatId: effectiveChatId,
executionId,
runId,
goRoute: '/api/mothership/execute',
autoExecuteTools: true,
interactive: false,
})
if (!result.success) {
logger.error(appendCopilotLogContext('Mothership execute failed', { messageId }), {
reqLogger.error('Mothership execute failed', {
error: result.error,
errors: result.errors,
})
@@ -120,7 +135,7 @@ export async function POST(req: NextRequest) {
)
}
logger.error(appendCopilotLogContext('Mothership execute error', { messageId }), {
logger.withMetadata({ messageId }).error('Mothership execute error', {
error: error instanceof Error ? error.message : 'Unknown error',
})

View File

@@ -61,6 +61,21 @@ export async function GET(
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
// Verify caller is either an org member or the invitee
const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase()
if (!isInvitee) {
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const org = await db
.select()
.from(organization)

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

@@ -9,10 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockVerifyCronAuth,
mockExecuteScheduleJob,
mockExecuteJobInline,
mockFeatureFlags,
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -22,6 +24,7 @@ const {
const mockDbSet = vi.fn().mockReturnValue({ where: mockDbWhere })
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbSet })
const mockEnqueue = vi.fn().mockResolvedValue('job-id-1')
const mockEnqueueWorkspaceDispatch = vi.fn().mockResolvedValue('job-id-1')
const mockStartJob = vi.fn().mockResolvedValue(undefined)
const mockCompleteJob = vi.fn().mockResolvedValue(undefined)
const mockMarkJobFailed = vi.fn().mockResolvedValue(undefined)
@@ -29,6 +32,7 @@ const {
return {
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
mockFeatureFlags: {
isTriggerDevEnabled: false,
isHosted: false,
@@ -38,6 +42,7 @@ const {
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -50,6 +55,8 @@ vi.mock('@/lib/auth/internal', () => ({
vi.mock('@/background/schedule-execution', () => ({
executeScheduleJob: mockExecuteScheduleJob,
executeJobInline: mockExecuteJobInline,
releaseScheduleLock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags)
@@ -68,6 +75,22 @@ vi.mock('@/lib/core/async-jobs', () => ({
shouldExecuteInline: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/core/bullmq', () => ({
isBullMQEnabled: vi.fn().mockReturnValue(true),
createBullMQJobData: vi.fn((payload: unknown) => ({ payload })),
}))
vi.mock('@/lib/core/workspace-dispatch', () => ({
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: vi.fn().mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
}),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -142,6 +165,18 @@ const MULTIPLE_SCHEDULES = [
},
]
const SINGLE_JOB = [
{
id: 'job-1',
cronExpression: '0 * * * *',
failedCount: 0,
lastQueuedAt: undefined,
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
sourceType: 'job',
},
]
function createMockRequest(): NextRequest {
const mockHeaders = new Map([
['authorization', 'Bearer test-cron-secret'],
@@ -211,30 +246,44 @@ describe('Scheduled Workflow Execution API Route', () => {
expect(data).toHaveProperty('executedCount', 2)
})
it('should queue mothership jobs to BullMQ when available', async () => {
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
const response = await GET(createMockRequest())
expect(response.status).toBe(200)
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'mothership-job-execution',
bullmqJobName: 'mothership-job-execution',
bullmqPayload: {
payload: {
scheduleId: 'job-1',
cronExpression: '0 * * * *',
failedCount: 0,
now: expect.any(String),
},
},
})
)
expect(mockExecuteJobInline).not.toHaveBeenCalled()
})
it('should enqueue preassigned correlation metadata for schedules', async () => {
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
const response = await GET(createMockRequest())
expect(response.status).toBe(200)
expect(mockEnqueue).toHaveBeenCalledWith(
'schedule-execution',
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
correlation: {
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
source: 'schedule',
workflowId: 'workflow-1',
scheduleId: 'schedule-1',
triggerType: 'schedule',
scheduledFor: '2025-01-01T00:00:00.000Z',
},
}),
{
id: 'schedule-execution-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
metadata: {
workflowId: 'workflow-1',
correlation: {
@@ -247,7 +296,7 @@ describe('Scheduled Workflow Execution API Route', () => {
scheduledFor: '2025-01-01T00:00:00.000Z',
},
},
}
})
)
})
})

View File

@@ -5,7 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
import { generateRequestId } from '@/lib/core/utils/request'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import {
executeJobInline,
executeScheduleJob,
@@ -73,6 +75,8 @@ export async function GET(request: NextRequest) {
cronExpression: workflowSchedule.cronExpression,
failedCount: workflowSchedule.failedCount,
lastQueuedAt: workflowSchedule.lastQueuedAt,
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
sourceUserId: workflowSchedule.sourceUserId,
sourceType: workflowSchedule.sourceType,
})
@@ -111,9 +115,44 @@ export async function GET(request: NextRequest) {
}
try {
const jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
})
const { getWorkflowById } = await import('@/lib/workflows/utils')
const resolvedWorkflow = schedule.workflowId
? await getWorkflowById(schedule.workflowId)
: null
const resolvedWorkspaceId = resolvedWorkflow?.workspaceId
let jobId: string
if (isBullMQEnabled()) {
if (!resolvedWorkspaceId) {
throw new Error(
`Missing workspace for scheduled workflow ${schedule.workflowId}; refusing to bypass workspace admission`
)
}
jobId = await enqueueWorkspaceDispatch({
id: executionId,
workspaceId: resolvedWorkspaceId,
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
bullmqPayload: createBullMQJobData(payload, {
workflowId: schedule.workflowId ?? undefined,
correlation,
}),
metadata: {
workflowId: schedule.workflowId ?? undefined,
correlation,
},
})
} else {
jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: {
workflowId: schedule.workflowId ?? undefined,
workspaceId: resolvedWorkspaceId ?? undefined,
correlation,
},
})
}
logger.info(
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
)
@@ -165,7 +204,7 @@ export async function GET(request: NextRequest) {
}
})
// Jobs always execute inline (no TriggerDev)
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
const jobPromises = dueJobs.map(async (job) => {
const queueTime = job.lastQueuedAt ?? queuedAt
const payload = {
@@ -176,7 +215,24 @@ export async function GET(request: NextRequest) {
}
try {
await executeJobInline(payload)
if (isBullMQEnabled()) {
if (!job.sourceWorkspaceId || !job.sourceUserId) {
throw new Error(`Mothership job ${job.id} is missing workspace/user ownership`)
}
await enqueueWorkspaceDispatch({
workspaceId: job.sourceWorkspaceId!,
lane: 'runtime',
queueName: 'mothership-job-execution',
bullmqJobName: 'mothership-job-execution',
bullmqPayload: createBullMQJobData(payload),
metadata: {
userId: job.sourceUserId,
},
})
} else {
await executeJobInline(payload)
}
} catch (error) {
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
error: error instanceof Error ? error.message : String(error),

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
@@ -96,6 +97,18 @@ export async function POST(req: NextRequest) {
requestId,
})
for (const skill of resultSkills) {
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.SKILL_CREATED,
resourceType: AuditResourceType.SKILL,
resourceId: skill.id,
resourceName: skill.name,
description: `Created/updated skill "${skill.name}"`,
})
}
return NextResponse.json({ success: true, data: resultSkills })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -158,6 +171,15 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
recordAudit({
workspaceId,
actorId: authResult.userId,
action: AuditAction.SKILL_DELETED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: `Deleted skill`,
})
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
@@ -166,6 +167,18 @@ export async function POST(req: NextRequest) {
requestId,
})
for (const tool of resultTools) {
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.CUSTOM_TOOL_CREATED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: tool.id,
resourceName: tool.title,
description: `Created/updated custom tool "${tool.title}"`,
})
}
return NextResponse.json({ success: true, data: resultTools })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -265,6 +278,15 @@ export async function DELETE(request: NextRequest) {
// Delete the tool
await db.delete(customTools).where(eq(customTools.id, toolId))
recordAudit({
workspaceId: tool.workspaceId || undefined,
actorId: userId,
action: AuditAction.CUSTOM_TOOL_DELETED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: toolId,
description: `Deleted custom tool`,
})
logger.info(`[${requestId}] Deleted tool: ${toolId}`)
return NextResponse.json({ success: true })
} catch (error) {

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,166 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import {
downloadWorkspaceFile,
getWorkspaceFileByName,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('FileManageAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const userId = auth.userId || searchParams.get('userId')
if (!userId) {
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
}
let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 })
}
const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
}
const operation = body.operation as string
try {
switch (operation) {
case 'write': {
const fileName = body.fileName as string | undefined
const content = body.content as string | undefined
const contentType = body.contentType as string | undefined
if (!fileName) {
return NextResponse.json(
{ success: false, error: 'fileName is required for write operation' },
{ status: 400 }
)
}
if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for write operation' },
{ status: 400 }
)
}
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
fileName,
mimeType
)
logger.info('File created', {
fileId: result.id,
name: fileName,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: result.id,
name: result.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(result.url),
},
})
}
case 'append': {
const fileName = body.fileName as string | undefined
const content = body.content as string | undefined
if (!fileName) {
return NextResponse.json(
{ success: false, error: 'fileName is required for append operation' },
{ status: 400 }
)
}
if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for append operation' },
{ status: 400 }
)
}
const existing = await getWorkspaceFileByName(workspaceId, fileName)
if (!existing) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileName}"` },
{ status: 404 }
)
}
const lockKey = `file-append:${workspaceId}:${existing.id}`
const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const acquired = await acquireLock(lockKey, lockValue, 30)
if (!acquired) {
return NextResponse.json(
{ success: false, error: 'File is busy, please retry' },
{ status: 409 }
)
}
try {
const existingBuffer = await downloadWorkspaceFile(existing)
const finalContent = existingBuffer.toString('utf-8') + content
const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)
logger.info('File appended', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: existing.id,
name: existing.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(existing.path),
},
})
} finally {
await releaseLock(lockKey, lockValue)
}
}
default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}. Supported: write, append` },
{ status: 400 }
)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('File operation failed', { operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { ImapFlow } from 'imapflow'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('ImapMailboxesAPI')
@@ -9,7 +10,6 @@ interface ImapMailboxRequest {
host: string
port: number
secure: boolean
rejectUnauthorized: boolean
username: string
password: string
}
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as ImapMailboxRequest
const { host, port, secure, rejectUnauthorized, username, password } = body
const { host, port, secure, username, password } = body
if (!host || !username || !password) {
return NextResponse.json(
@@ -31,8 +31,14 @@ export async function POST(request: NextRequest) {
)
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 })
}
const client = new ImapFlow({
host,
host: hostValidation.resolvedIP!,
servername: host,
port: port || 993,
secure: secure ?? true,
auth: {
@@ -40,7 +46,7 @@ export async function POST(request: NextRequest) {
pass: password,
},
tls: {
rejectUnauthorized: rejectUnauthorized ?? true,
rejectUnauthorized: true,
},
logger: false,
})
@@ -79,21 +85,12 @@ export async function POST(request: NextRequest) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error fetching IMAP mailboxes:', errorMessage)
let userMessage = 'Failed to connect to IMAP server'
let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.'
if (
errorMessage.includes('AUTHENTICATIONFAILED') ||
errorMessage.includes('Invalid credentials')
) {
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
userMessage = 'Could not find IMAP server. Please check the hostname.'
} else if (errorMessage.includes('ECONNREFUSED')) {
userMessage = 'Connection refused. Please check the port and SSL settings.'
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
userMessage =
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
} else if (errorMessage.includes('timeout')) {
userMessage = 'Connection timed out. Please check your network and server settings.'
}
return NextResponse.json({ success: false, message: userMessage }, { 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

@@ -1,4 +1,5 @@
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const S_IFMT = 0o170000
const S_IFDIR = 0o040000
@@ -91,16 +92,23 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er
* Creates an SSH connection for SFTP using the provided configuration.
* Uses ssh2 library defaults which align with OpenSSH standards.
*/
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
export async function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
const host = config.host
if (!host || host.trim() === '') {
throw new Error('Host is required. Please provide a valid hostname or IP address.')
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
@@ -111,7 +119,7 @@ export function createSftpConnection(config: SftpConnectionConfig): Promise<Clie
}
const connectConfig: ConnectConfig = {
host: host.trim(),
host: resolvedHost,
port,
username: config.username,
}

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
@@ -56,6 +57,15 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const validatedData = SmtpSendSchema.parse(body)
const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost')
if (!hostValidation.isValid) {
logger.warn(`[${requestId}] SMTP host validation failed`, {
host: validatedData.smtpHost,
error: hostValidation.error,
})
return NextResponse.json({ success: false, error: hostValidation.error }, { status: 400 })
}
logger.info(`[${requestId}] Sending email via SMTP`, {
host: validatedData.smtpHost,
port: validatedData.smtpPort,
@@ -64,8 +74,13 @@ export async function POST(request: NextRequest) {
secure: validatedData.smtpSecure,
})
// Pin the pre-resolved IP to prevent DNS rebinding (TOCTOU) attacks.
// Pass resolvedIP as the host so nodemailer connects to the validated address,
// and set servername for correct TLS SNI/certificate validation.
const pinnedHost = hostValidation.resolvedIP ?? validatedData.smtpHost
const transporter = nodemailer.createTransport({
host: validatedData.smtpHost,
host: pinnedHost,
port: validatedData.smtpPort,
secure: validatedData.smtpSecure === 'SSL',
auth: {
@@ -74,12 +89,8 @@ export async function POST(request: NextRequest) {
},
tls:
validatedData.smtpSecure === 'None'
? {
rejectUnauthorized: false,
}
: {
rejectUnauthorized: true,
},
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
})
const contentType = validatedData.contentType || 'text'
@@ -189,16 +200,16 @@ export async function POST(request: NextRequest) {
if (isNodeError(error)) {
if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed - check username and password'
} else if (error.code === 'ECONNECTION' || error.code === 'ECONNREFUSED') {
} else if (
error.code === 'ECONNECTION' ||
error.code === 'ECONNREFUSED' ||
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT'
) {
errorMessage = 'Could not connect to SMTP server - check host and port'
} else if (error.code === 'ECONNRESET') {
errorMessage = 'Connection was reset by SMTP server'
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'SMTP server connection timeout'
}
}
// Check for SMTP response codes
const hasResponseCode = (err: unknown): err is { responseCode: number } => {
return typeof err === 'object' && err !== null && 'responseCode' in err
}

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('SSHUtils')
@@ -108,16 +109,23 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
*/
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
export async function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
const host = config.host
if (!host || host.trim() === '') {
throw new Error('Host is required. Please provide a valid hostname or IP address.')
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
@@ -128,7 +136,7 @@ export function createSSHConnection(config: SSHConnectionConfig): Promise<Client
}
const connectConfig: ConnectConfig = {
host: host.trim(),
host: resolvedHost,
port,
username: config.username,
}

View File

@@ -1,25 +1,7 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import {
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -31,12 +13,19 @@ import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/
const logger = createLogger('AdminWorkflowDeployAPI')
const ADMIN_ACTOR_ID = 'admin-api'
interface RouteParams {
id: string
}
/**
* POST — Deploy a workflow via admin API.
*
* `userId` is set to the workflow owner so that webhook credential resolution
* (OAuth token lookups for providers like Airtable, Attio, etc.) uses a real
* user. `actorId` is set to `'admin-api'` so that the `deployedBy` field on
* the deployment version and audit log entries are correctly attributed to an
* admin action rather than the workflow owner.
*/
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
@@ -48,140 +37,28 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return notFoundResponse('Workflow')
}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return badRequestResponse('Workflow has no saved state')
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) {
return
}
}
await undeployWorkflow({ workflowId })
}
const deployResult = await deployWorkflow({
const result = await performFullDeploy({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
workflowName: workflowRecord.name,
})
if (!deployResult.success) {
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
if (!deployResult.deploymentVersionId) {
await undeployWorkflow({ workflowId })
return internalErrorResponse('Failed to resolve deployment version')
}
const workflowData = workflowRecord as Record<string, unknown>
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks: normalizedData.blocks,
workflowName: workflowRecord.name,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
previousVersionId,
request,
actorId: 'admin-api',
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await rollbackDeployment()
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
if (!result.success) {
if (result.errorCode === 'not_found') return notFoundResponse('Workflow state')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to deploy workflow')
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
db,
deployResult.deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Admin API: Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await rollbackDeployment()
return internalErrorResponse(scheduleResult.error || 'Failed to create schedule')
}
if (previousVersionId && previousVersionId !== deployResult.deploymentVersionId) {
try {
logger.info(`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId}`)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
logger.info(
`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`
)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' })
logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
version: result.version!,
deployedAt: result.deployedAt!.toISOString(),
warnings: result.warnings,
}
return singleResponse(response)
@@ -191,7 +68,7 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
@@ -202,19 +79,17 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
const result = await undeployWorkflow({ workflowId })
const result = await performFullUndeploy({
workflowId,
userId: workflowRecord.userId,
requestId,
actorId: 'admin-api',
})
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
await cleanupWebhooksForWorkflow(
workflowId,
workflowRecord as Record<string, unknown>,
requestId
)
await removeMcpToolsForWorkflow(workflowId, requestId)
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {

View File

@@ -13,12 +13,12 @@
*/
import { db } from '@sim/db'
import { templates, workflowBlocks, workflowEdges } from '@sim/db/schema'
import { workflowBlocks, workflowEdges } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
@@ -69,7 +69,7 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context) => {
const { id: workflowId } = await context.params
try {
@@ -79,12 +79,18 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
await db.update(templates).set({ workflowId: null }).where(eq(templates.workflowId, workflowId))
await archiveWorkflow(workflowId, {
const result = await performDeleteWorkflow({
workflowId,
userId: workflowData.userId,
skipLastWorkflowGuard: true,
requestId: `admin-workflow-${workflowId}`,
actorId: 'admin-api',
})
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to delete workflow')
}
logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`)
return NextResponse.json({ success: true, workflowId })

View File

@@ -1,16 +1,7 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -18,7 +9,6 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
@@ -43,144 +33,22 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('Invalid version number')
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return notFoundResponse('Deployment version')
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return internalErrorResponse('Invalid deployed state structure')
}
const workflowData = workflowRecord as Record<string, unknown>
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
const result = await performActivateVersion({
workflowId,
workflow: workflowData,
version: versionNum,
userId: workflowRecord.userId,
blocks,
workflow: workflowRecord as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
request,
actorId: 'admin-api',
})
if (!triggerSaveResult.success) {
logger.error(
`[${requestId}] Admin API: Failed to sync triggers for workflow ${workflowId}`,
triggerSaveResult.error
)
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(workflowId, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
return internalErrorResponse(scheduleResult.error || 'Failed to sync schedules')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
if (result.errorCode === 'not_found') return notFoundResponse('Deployment version')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to activate version')
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Admin API: Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId,
requestId,
state: versionRow.state,
context: 'activate',
})
logger.info(
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
)
@@ -189,14 +57,12 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
warnings: result.warnings,
})
} catch (error) {
logger.error(
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
{
error,
}
{ error }
)
return internalErrorResponse('Failed to activate deployment version')
}

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils'
@@ -83,17 +83,15 @@ export async function POST(req: NextRequest) {
const chatId = parsed.chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
logger.error(
appendCopilotLogContext('Received headless copilot chat start request', { messageId }),
{
workflowId: resolved.workflowId,
workflowName: parsed.workflowName,
chatId,
mode: transportMode,
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
}
)
const reqLogger = logger.withMetadata({ messageId })
reqLogger.info('Received headless copilot chat start request', {
workflowId: resolved.workflowId,
workflowName: parsed.workflowName,
chatId,
mode: transportMode,
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
})
const requestPayload = {
message: parsed.message,
workflowId: resolved.workflowId,
@@ -104,10 +102,24 @@ export async function POST(req: NextRequest) {
chatId,
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
await createRunSegment({
id: runId,
executionId,
chatId,
userId: auth.userId,
workflowId: resolved.workflowId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId: auth.userId,
workflowId: resolved.workflowId,
chatId,
executionId,
runId,
goRoute: '/api/mcp',
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
@@ -129,7 +141,7 @@ export async function POST(req: NextRequest) {
)
}
logger.error(appendCopilotLogContext('Headless copilot request failed', { messageId }), {
logger.withMetadata({ messageId }).error('Headless copilot request failed', {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })

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

@@ -1,6 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { DispatchQueueFullError } from '@/lib/core/workspace-dispatch'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
@@ -41,10 +43,25 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string }> }
) {
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleWebhookPost(request, params)
} finally {
ticket.release()
}
}
async function handleWebhookPost(
request: NextRequest,
params: Promise<{ path: string }>
): Promise<NextResponse> {
const requestId = generateRequestId()
const { path } = await params
// Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
if (earlyChallenge) {
return earlyChallenge
@@ -140,17 +157,30 @@ export async function POST(
continue
}
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
try {
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
} catch (error) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
throw error
}
}
// Return the last successful response, or a combined response for multiple webhooks
if (responses.length === 0) {
return new NextResponse('No webhooks processed successfully', { status: 500 })
}

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