Compare commits

..

38 Commits

Author SHA1 Message Date
Waleed
14089f7dbb v0.6.14: performance improvements, connectors UX, collapsed sidebar actions 2026-03-27 13:07:59 -07:00
Waleed
e615816dce v0.6.13: emcn standardization, granola and ketch integrations, security hardening, connectors improvements 2026-03-27 00:16:37 -07:00
Waleed
ca87d7ce29 v0.6.12: billing, blogs UI 2026-03-26 01:19:23 -07:00
Waleed
6bebbc5e29 v0.6.11: billing fixes, rippling, hubspot, UI improvements, demo modal 2026-03-25 22:54:56 -07:00
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
ed9a71f0af v0.6.9: general ux improvements for tables, mothership 2026-03-24 17:03:24 -07:00
Siddharth Ganesan
c78c870fda v0.6.8: mothership tool loop
v0.6.8: mothership tool loop
2026-03-24 04:06:19 -07:00
Waleed
19442f19e2 v0.6.7: kb improvements, edge z index fix, captcha, new trust center, block classifications 2026-03-21 12:43:33 -07:00
Waleed
1731a4d7f0 v0.6.6: landing improvements, styling consistency, mothership table renaming 2026-03-19 23:58:30 -07:00
Waleed
9fcd02fd3b v0.6.5: email validation, integrations page, mothership and custom tool fixes 2026-03-19 16:08:30 -07:00
Waleed
ff7b5b528c v0.6.4: subflows, docusign, ashby new tools, box, workday, billing bug fixes 2026-03-18 23:12:36 -07:00
Waleed
30f2d1a0fc v0.6.3: hubspot integration, kb block improvements 2026-03-18 11:19:55 -07:00
Waleed
4bd0731871 v0.6.2: mothership stability, chat iframe embedding, KB upserts, new blog post 2026-03-18 03:29:39 -07:00
Waleed
4f3bc37fe4 v0.6.1: added better auth admin plugin 2026-03-17 15:16:16 -07:00
Waleed
84d6fdc423 v0.6: mothership, tables, connectors 2026-03-17 12:21:15 -07:00
Vikhyath Mondreti
4c12914d35 v0.5.113: jira, ashby, google ads, grain updates 2026-03-12 22:54:25 -07:00
Waleed
e9bdc57616 v0.5.112: trace spans improvements, fathom integration, jira fixes, canvas navigation updates 2026-03-12 13:30:20 -07:00
Vikhyath Mondreti
36612ae42a v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530) 2026-03-11 17:47:28 -07:00
Waleed
1c2c2c65d4 v0.5.110: webhook execution speedups, SSRF patches 2026-03-11 15:00:24 -07:00
Waleed
ecd3536a72 v0.5.109: obsidian and evernote integrations, slack fixes, remove memory instrumentation 2026-03-09 10:40:37 -07:00
Vikhyath Mondreti
8c0a2e04b1 v0.5.108: workflow input params in agent tools, bun upgrade, dropdown selectors for 14 blocks 2026-03-06 21:02:25 -08:00
Waleed
6586c5ce40 v0.5.107: new reddit, slack tools 2026-03-05 22:48:20 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

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

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

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

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

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

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

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>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
223 changed files with 2139 additions and 40670 deletions

View File

@@ -74,10 +74,6 @@ 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
@@ -117,12 +113,10 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
5. Start development servers:
```bash
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
bun run dev:full # Starts both Next.js app and realtime socket server
```
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).
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
## 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 | Sim Docs',
template: '%s',
},
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

@@ -195,17 +195,6 @@ 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

@@ -26,8 +26,6 @@ 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,7 +25,6 @@ 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',
],
@@ -43,7 +42,6 @@ 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',
],
@@ -61,7 +59,6 @@ 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',
],
@@ -78,7 +75,6 @@ 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

@@ -1,43 +0,0 @@
'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

@@ -1,62 +0,0 @@
'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

@@ -1,291 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
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'>
<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>
<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,10 +25,6 @@ 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'
@@ -36,7 +32,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={forcedTheme}
forcedTheme={isLightModePage ? 'light' : undefined}
{...props}
>
{children}

View File

@@ -1,156 +0,0 @@
'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

@@ -1,68 +0,0 @@
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

@@ -1,127 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,76 +0,0 @@
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

@@ -1,66 +0,0 @@
'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

@@ -1,256 +0,0 @@
'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

@@ -1,23 +0,0 @@
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

@@ -1,219 +0,0 @@
'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

@@ -1,56 +0,0 @@
'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

@@ -1,432 +0,0 @@
'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 { 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 { 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)
useWorkflowRegistry.setState((state) => ({
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
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()
useWorkflowRegistry.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflows
return {
workflows: rest,
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
hydration:
state.hydration.workflowId === workflowId
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
: state.hydration,
}
})
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

@@ -1,50 +0,0 @@
'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

@@ -1,25 +0,0 @@
import type React from 'react'
import type { Metadata } from 'next'
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 }) {
return (
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
{children}
</div>
)
}

View File

@@ -1,215 +0,0 @@
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

@@ -5,7 +5,6 @@ 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 {
@@ -540,26 +539,10 @@ 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,

View File

@@ -12,7 +12,6 @@ const {
mockReturning,
mockSelect,
mockFrom,
mockWhere,
mockAuthenticate,
mockCreateUnauthorizedResponse,
mockCreateBadRequestResponse,
@@ -24,7 +23,6 @@ const {
mockReturning: vi.fn(),
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockAuthenticate: vi.fn(),
mockCreateUnauthorizedResponse: vi.fn(),
mockCreateBadRequestResponse: vi.fn(),
@@ -83,8 +81,7 @@ describe('Copilot Feedback API Route', () => {
mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockResolvedValue([])
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockResolvedValue([])
mockFrom.mockResolvedValue([])
mockCreateRequestTracker.mockReturnValue({
requestId: 'test-request-id',
@@ -389,7 +386,7 @@ edges:
isAuthenticated: true,
})
mockWhere.mockResolvedValueOnce([])
mockFrom.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -400,7 +397,7 @@ edges:
expect(responseData.feedback).toEqual([])
})
it('should only return feedback records for the authenticated user', async () => {
it('should return all feedback records', async () => {
mockAuthenticate.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
@@ -418,8 +415,19 @@ 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'),
},
]
mockWhere.mockResolvedValueOnce(mockFeedback)
mockFrom.mockResolvedValueOnce(mockFeedback)
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -427,14 +435,9 @@ edges:
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toHaveLength(1)
expect(responseData.feedback).toHaveLength(2)
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
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')
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
})
it('should handle database errors gracefully', async () => {
@@ -443,7 +446,7 @@ edges:
isAuthenticated: true,
})
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -459,7 +462,7 @@ edges:
isAuthenticated: true,
})
mockWhere.mockResolvedValueOnce([])
mockFrom.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)

View File

@@ -1,7 +1,6 @@
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 {
@@ -110,7 +109,7 @@ export async function POST(req: NextRequest) {
/**
* GET /api/copilot/feedback
* Get feedback records for the authenticated user
* Get all feedback records (for analytics)
*/
export async function GET(req: NextRequest) {
const tracker = createRequestTracker()
@@ -124,7 +123,7 @@ export async function GET(req: NextRequest) {
return createUnauthorizedResponse()
}
// Get feedback records for the authenticated user only
// Get all feedback records
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
@@ -138,7 +137,6 @@ 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,10 +1,6 @@
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')
@@ -20,11 +16,6 @@ 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,10 +1,6 @@
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')
@@ -26,11 +22,6 @@ 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

@@ -26,14 +26,6 @@ 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

@@ -1,160 +0,0 @@
/**
* @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,10 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue } from '@/lib/core/async-jobs'
import { getJobQueue, JOB_STATUS } 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')
@@ -25,54 +23,68 @@ export async function GET(
const authenticatedUserId = authResult.userId
const dispatchJob = await getDispatchJobRecord(taskId)
const jobQueue = await getJobQueue()
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
const job = await jobQueue.getJob(taskId)
if (!job && !dispatchJob) {
if (!job) {
return createErrorResponse('Task not found', 404)
}
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
if (metadataToCheck?.workflowId) {
if (job.metadata?.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
const accessCheck = await verifyWorkflowAccess(
authenticatedUserId,
metadataToCheck.workflowId as string
job.metadata.workflowId as string
)
if (!accessCheck.hasAccess) {
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
return createErrorResponse('Access denied', 403)
}
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
const workflow = await getWorkflowById(job.metadata.workflowId as string)
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
return createErrorResponse('API key is not authorized for this workspace', 403)
}
}
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
return createErrorResponse('Access denied', 403)
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
return createErrorResponse('Access denied', 403)
}
const presented = presentDispatchOrJobStatus(dispatchJob, job)
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
const response: any = {
success: true,
taskId,
status: presented.status,
metadata: presented.metadata,
status: mappedStatus,
metadata: {
startedAt: job.startedAt,
},
}
if (presented.output !== undefined) response.output = presented.output
if (presented.error !== undefined) response.error = presented.error
if (presented.estimatedDuration !== undefined) {
response.estimatedDuration = presented.estimatedDuration
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
}
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', connectorType: 'jira' }])
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('DELETE')

View File

@@ -292,10 +292,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
const connectorDocuments = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
const docs = await tx
@@ -309,12 +306,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
)
)
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 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
@@ -333,23 +328,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
throw new Error('Connector not found')
}
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
return docs
})
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 deleteDocumentStorageFiles(connectorDocuments, requestId)
logger.info(
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
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`)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
@@ -361,11 +349,7 @@ 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: deleteDocuments ? docCount : 0,
documentsKept: deleteDocuments ? 0 : docCount,
},
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
request,
})

View File

@@ -18,7 +18,6 @@ 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'
@@ -728,25 +727,10 @@ 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

@@ -2,7 +2,6 @@ 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'
@@ -72,24 +71,10 @@ 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,

View File

@@ -61,21 +61,6 @@ 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

@@ -9,12 +9,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockVerifyCronAuth,
mockExecuteScheduleJob,
mockExecuteJobInline,
mockFeatureFlags,
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -24,7 +22,6 @@ 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)
@@ -32,7 +29,6 @@ const {
return {
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
mockFeatureFlags: {
isTriggerDevEnabled: false,
isHosted: false,
@@ -42,7 +38,6 @@ const {
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -55,8 +50,6 @@ 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)
@@ -75,22 +68,6 @@ 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' })),
@@ -165,18 +142,6 @@ 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'],
@@ -246,44 +211,30 @@ 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(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect(mockEnqueue).toHaveBeenCalledWith(
'schedule-execution',
expect.objectContaining({
id: 'schedule-execution-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
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',
},
}),
{
metadata: {
workflowId: 'workflow-1',
correlation: {
@@ -296,7 +247,7 @@ describe('Scheduled Workflow Execution API Route', () => {
scheduledFor: '2025-01-01T00:00:00.000Z',
},
},
})
}
)
})
})

View File

@@ -5,9 +5,7 @@ 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,
@@ -75,8 +73,6 @@ export async function GET(request: NextRequest) {
cronExpression: workflowSchedule.cronExpression,
failedCount: workflowSchedule.failedCount,
lastQueuedAt: workflowSchedule.lastQueuedAt,
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
sourceUserId: workflowSchedule.sourceUserId,
sourceType: workflowSchedule.sourceType,
})
@@ -115,40 +111,9 @@ export async function GET(request: NextRequest) {
}
try {
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, correlation },
})
}
const jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
})
logger.info(
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
)
@@ -200,7 +165,7 @@ export async function GET(request: NextRequest) {
}
})
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
// Jobs always execute inline (no TriggerDev)
const jobPromises = dueJobs.map(async (job) => {
const queueTime = job.lastQueuedAt ?? queuedAt
const payload = {
@@ -211,24 +176,7 @@ export async function GET(request: NextRequest) {
}
try {
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)
}
await executeJobInline(payload)
} catch (error) {
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
error: error instanceof Error ? error.message : String(error),

View File

@@ -2,7 +2,6 @@ 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')
@@ -10,6 +9,7 @@ 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, username, password } = body
const { host, port, secure, rejectUnauthorized, username, password } = body
if (!host || !username || !password) {
return NextResponse.json(
@@ -31,14 +31,8 @@ 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: hostValidation.resolvedIP!,
servername: host,
host,
port: port || 993,
secure: secure ?? true,
auth: {
@@ -46,7 +40,7 @@ export async function POST(request: NextRequest) {
pass: password,
},
tls: {
rejectUnauthorized: true,
rejectUnauthorized: rejectUnauthorized ?? true,
},
logger: false,
})
@@ -85,12 +79,21 @@ 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. Please check your connection settings.'
let userMessage = 'Failed to connect to IMAP server'
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

@@ -1,5 +1,4 @@
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
@@ -92,23 +91,16 @@ 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 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()
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
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() !== ''
@@ -119,7 +111,7 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis
}
const connectConfig: ConnectConfig = {
host: resolvedHost,
host: host.trim(),
port,
username: config.username,
}

View File

@@ -3,7 +3,6 @@ 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'
@@ -57,15 +56,6 @@ 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,
@@ -74,13 +64,8 @@ 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: pinnedHost,
host: validatedData.smtpHost,
port: validatedData.smtpPort,
secure: validatedData.smtpSecure === 'SSL',
auth: {
@@ -89,8 +74,12 @@ export async function POST(request: NextRequest) {
},
tls:
validatedData.smtpSecure === 'None'
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
? {
rejectUnauthorized: false,
}
: {
rejectUnauthorized: true,
},
})
const contentType = validatedData.contentType || 'text'
@@ -200,16 +189,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' ||
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT'
) {
} else if (error.code === 'ECONNECTION' || error.code === 'ECONNREFUSED') {
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,6 +1,5 @@
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')
@@ -109,23 +108,16 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
*/
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()
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
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() !== ''
@@ -136,7 +128,7 @@ export async function createSSHConnection(config: SSHConnectionConfig): Promise<
}
const connectConfig: ConnectConfig = {
host: resolvedHost,
host: host.trim(),
port,
username: config.username,
}

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -105,24 +104,10 @@ 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,

View File

@@ -1,8 +1,6 @@
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,
@@ -43,25 +41,10 @@ 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
@@ -157,30 +140,17 @@ async function handleWebhookPost(
continue
}
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
}
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
}
// 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 })
}

View File

@@ -10,18 +10,15 @@ const {
mockAuthorizeWorkflowByWorkspacePermission,
mockPreprocessExecution,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockPreprocessExecution: vi.fn(),
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
mockEnqueueWorkspaceDispatch: vi.fn().mockResolvedValue('job-123'),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
hasExternalApiCredentials: vi.fn().mockReturnValue(true),
AuthType: {
SESSION: 'session',
API_KEY: 'api_key',
@@ -47,16 +44,6 @@ vi.mock('@/lib/core/async-jobs', () => ({
markJobFailed: vi.fn(),
}),
shouldExecuteInline: vi.fn().mockReturnValue(false),
shouldUseBullMQ: vi.fn().mockReturnValue(true),
}))
vi.mock('@/lib/core/bullmq', () => ({
createBullMQJobData: vi.fn((payload: unknown, metadata?: unknown) => ({ payload, metadata })),
}))
vi.mock('@/lib/core/workspace-dispatch', () => ({
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
waitForDispatchJob: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
@@ -145,13 +132,22 @@ describe('workflow execute async route', () => {
expect(response.status).toBe(202)
expect(body.executionId).toBe('execution-123')
expect(body.jobId).toBe('job-123')
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect(mockEnqueue).toHaveBeenCalledWith(
'workflow-execution',
expect.objectContaining({
id: 'execution-123',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
workflowId: 'workflow-1',
userId: 'actor-1',
executionId: 'execution-123',
requestId: 'req-12345678',
correlation: {
executionId: 'execution-123',
requestId: 'req-12345678',
source: 'workflow',
workflowId: 'workflow-1',
triggerType: 'manual',
},
}),
{
metadata: {
workflowId: 'workflow-1',
userId: 'actor-1',
@@ -163,7 +159,7 @@ describe('workflow execute async route', () => {
triggerType: 'manual',
},
},
})
}
)
})
})

View File

@@ -2,10 +2,8 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { getJobQueue, shouldExecuteInline, shouldUseBullMQ } from '@/lib/core/async-jobs'
import { createBullMQJobData } from '@/lib/core/bullmq'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import {
createTimeoutAbortController,
getTimeoutErrorMessage,
@@ -14,13 +12,6 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
DispatchQueueFullError,
enqueueWorkspaceDispatch,
type WorkspaceDispatchLane,
waitForDispatchJob,
} from '@/lib/core/workspace-dispatch'
import { createBufferedExecutionStream } from '@/lib/execution/buffered-stream'
import {
buildNextCallChain,
parseCallChain,
@@ -42,11 +33,6 @@ import {
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import {
DIRECT_WORKFLOW_JOB_NAME,
type QueuedWorkflowExecutionPayload,
type QueuedWorkflowExecutionResult,
} from '@/lib/workflows/executor/queued-workflow-execution'
import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
@@ -118,8 +104,6 @@ const ExecuteWorkflowSchema = z.object({
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const INLINE_TRIGGER_TYPES = new Set<CoreTriggerType>(['manual', 'workflow'])
function resolveOutputIds(
selectedOutputs: string[] | undefined,
blocks: Record<string, any>
@@ -177,7 +161,6 @@ type AsyncExecutionParams = {
requestId: string
workflowId: string
userId: string
workspaceId: string
input: any
triggerType: CoreTriggerType
executionId: string
@@ -185,8 +168,7 @@ type AsyncExecutionParams = {
}
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
params
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
const correlation = {
executionId,
@@ -199,7 +181,6 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
const payload: WorkflowExecutionPayload = {
workflowId,
userId,
workspaceId,
input,
triggerType,
executionId,
@@ -209,42 +190,22 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
}
try {
const useBullMQ = shouldUseBullMQ()
const jobQueue = useBullMQ ? null : await getJobQueue()
const jobId = useBullMQ
? await enqueueWorkspaceDispatch({
id: executionId,
workspaceId,
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: createBullMQJobData(payload, {
workflowId,
userId,
correlation,
}),
metadata: {
workflowId,
userId,
correlation,
},
})
: await jobQueue!.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
})
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
})
logger.info(`[${requestId}] Queued async workflow execution`, {
workflowId,
jobId,
})
if (shouldExecuteInline() && jobQueue) {
const inlineJobQueue = jobQueue
if (shouldExecuteInline()) {
void (async () => {
try {
await inlineJobQueue.startJob(jobId)
await jobQueue.startJob(jobId)
const output = await executeWorkflowJob(payload)
await inlineJobQueue.completeJob(jobId, output)
await jobQueue.completeJob(jobId, output)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`[${requestId}] Async workflow execution failed`, {
@@ -252,7 +213,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
error: errorMessage,
})
try {
await inlineJobQueue.markJobFailed(jobId, errorMessage)
await jobQueue.markJobFailed(jobId, errorMessage)
} catch (markFailedError) {
logger.error(`[${requestId}] Failed to mark job as failed`, {
jobId,
@@ -278,17 +239,6 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
{ status: 202 }
)
} catch (error: any) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
logger.error(`[${requestId}] Failed to queue async execution`, error)
return NextResponse.json(
{ error: `Failed to queue async execution: ${error.message}` },
@@ -297,31 +247,6 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
}
}
async function enqueueDirectWorkflowExecution(
payload: QueuedWorkflowExecutionPayload,
priority: number,
lane: WorkspaceDispatchLane
) {
return enqueueWorkspaceDispatch({
id: payload.metadata.executionId,
workspaceId: payload.metadata.workspaceId,
lane,
queueName: 'workflow-execution',
bullmqJobName: DIRECT_WORKFLOW_JOB_NAME,
bullmqPayload: createBullMQJobData(payload, {
workflowId: payload.metadata.workflowId,
userId: payload.metadata.userId,
correlation: payload.metadata.correlation,
}),
metadata: {
workflowId: payload.metadata.workflowId,
userId: payload.metadata.userId,
correlation: payload.metadata.correlation,
},
priority,
})
}
/**
* POST /api/workflows/[id]/execute
*
@@ -329,27 +254,6 @@ async function enqueueDirectWorkflowExecution(
* Supports both SSE streaming (for interactive/manual runs) and direct JSON responses (for background jobs).
*/
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers)
if (isSessionRequest) {
return handleExecutePost(req, params)
}
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleExecutePost(req, params)
} finally {
ticket.release()
}
}
async function handleExecutePost(
req: NextRequest,
params: Promise<{ id: string }>
): Promise<NextResponse | Response> {
const requestId = generateRequestId()
const { id: workflowId } = await params
@@ -680,7 +584,6 @@ async function handleExecutePost(
requestId,
workflowId,
userId: actorUserId,
workspaceId,
input,
triggerType: loggingTriggerType,
executionId,
@@ -773,116 +676,30 @@ async function handleExecutePost(
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
if (shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)) {
try {
const dispatchJobId = await enqueueDirectWorkflowExecution(
{
workflow,
metadata,
input: processedInput,
variables: executionVariables,
selectedOutputs,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
timeoutMs: preprocessResult.executionTimeout?.sync,
runFromBlock: resolvedRunFromBlock,
},
5,
'interactive'
)
const resultRecord = await waitForDispatchJob(
dispatchJobId,
(preprocessResult.executionTimeout?.sync ?? 300000) + 30000
)
if (resultRecord.status === 'failed') {
return NextResponse.json(
{
success: false,
executionId,
error: resultRecord.error ?? 'Workflow execution failed',
},
{ status: 500 }
)
}
const result = resultRecord.output as QueuedWorkflowExecutionResult
const resultForResponseBlock = {
success: result.success,
logs: result.logs,
output: result.output,
}
if (
auth.authType !== AuthType.INTERNAL_JWT &&
workflowHasResponseBlock(resultForResponseBlock)
) {
return createHttpResponseFromBlock(resultForResponseBlock)
}
return NextResponse.json(
{
success: result.success,
executionId,
output: result.output,
error: result.error,
metadata: result.metadata,
},
{ status: result.statusCode ?? 200 }
)
} catch (error: unknown) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`)
return NextResponse.json(
{
success: false,
error: errorMessage,
},
{ status: 500 }
)
}
}
const timeoutController = createTimeoutAbortController(
preprocessResult.executionTimeout?.sync
)
try {
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
@@ -992,53 +809,6 @@ async function handleExecutePost(
}
if (shouldUseDraftState) {
const shouldDispatchViaQueue = shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)
if (shouldDispatchViaQueue) {
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
await enqueueDirectWorkflowExecution(
{
workflow,
metadata,
input: processedInput,
variables: executionVariables,
selectedOutputs,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
timeoutMs: preprocessResult.executionTimeout?.sync,
runFromBlock: resolvedRunFromBlock,
streamEvents: true,
},
1,
'interactive'
)
return new NextResponse(createBufferedExecutionStream(executionId), {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
}
logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`)
} else {
logger.info(`[${requestId}] Using streaming API response`)
@@ -1507,17 +1277,6 @@ async function handleExecutePost(
},
})
} catch (error: any) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
logger.error(`[${requestId}] Failed to start workflow execution:`, error)
return NextResponse.json(
{ error: error.message || 'Failed to start workflow execution' },

View File

@@ -6,6 +6,7 @@ import {
updateApiKeyLastUsed,
} from '@/lib/api-key/service'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowMiddleware')
@@ -80,6 +81,11 @@ export async function validateWorkflowAccess(
}
}
const internalSecret = request.headers.get('X-Internal-Secret')
if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
return { workflow }
}
let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {

View File

@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -25,7 +25,6 @@ const CreateWorkflowSchema = z.object({
workspaceId: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
deduplicate: z.boolean().optional(),
})
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
@@ -127,13 +126,12 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const {
id: clientId,
name: requestedName,
name,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
} = CreateWorkflowSchema.parse(body)
if (!workspaceId) {
@@ -164,6 +162,19 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
@@ -203,49 +214,30 @@ export async function POST(req: NextRequest) {
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
let name = requestedName
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, name),
]
if (deduplicate) {
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
} else {
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, requestedName),
]
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
} else {
duplicateConditions.push(isNull(workflow.folderId))
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${requestedName}" already exists in this folder` },
{ status: 409 }
)
}
duplicateConditions.push(isNull(workflow.folderId))
}
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${name}" already exists in this folder` },
{ status: 409 }
)
}
await db.insert(workflow).values({
id: workflowId,

View File

@@ -79,22 +79,6 @@ vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
}))
vi.mock('@/components/emails', () => ({
WorkspaceInvitationEmail: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getFromEmailAddress: vi.fn().mockReturnValue('noreply@test.com'),
}))
vi.mock('@react-email/render', () => ({
render: vi.fn().mockResolvedValue('<html></html>'),
}))
vi.mock('@sim/db', () => ({
db: {
select: () => mockDbSelect(),
@@ -187,31 +171,9 @@ describe('Workspace Invitation [invitationId] API Route', () => {
})
describe('GET /api/workspaces/invitations/[invitationId]', () => {
it('should return invitation details when caller is the invitee', async () => {
const session = createSession({ userId: mockUser.id, email: 'invited@example.com' })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toMatchObject({
id: 'invitation-789',
email: 'invited@example.com',
status: 'pending',
workspaceName: 'Test Workspace',
})
})
it('should return invitation details when caller is a workspace admin', async () => {
it('should return invitation details when called without token', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
@@ -229,22 +191,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
})
})
it('should return 403 when caller is neither invitee nor workspace admin', async () => {
const session = createSession({ userId: mockUser.id, email: 'unrelated@example.com' })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ error: 'Insufficient permissions' })
})
it('should redirect to login when unauthenticated with token', async () => {
mockGetSession.mockResolvedValue(null)

View File

@@ -198,15 +198,6 @@ export async function GET(
)
}
const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase()
if (!isInvitee) {
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
}
return NextResponse.json({
...invitation,
workspaceName: workspaceDetails.name,

View File

@@ -8,9 +8,7 @@ const baseUrl = getBaseUrl()
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
absolute: 'Sim — Build AI Agents & Run Your Agentic Workforce',
},
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
keywords:

View File

@@ -16,6 +16,7 @@ const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
export function NavTour() {
@@ -24,6 +25,9 @@ export function NavTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: navTourSteps,
storageKey: NAV_TOUR_STORAGE_KEY,
autoStartDelay: 1200,
resettable: true,
triggerEvent: START_NAV_TOUR_EVENT,
tourName: 'Navigation tour',
disabled: isWorkflowPage,

View File

@@ -12,11 +12,17 @@ const FADE_OUT_MS = 80
interface UseTourOptions {
/** Tour step definitions */
steps: Step[]
/** localStorage key for completion persistence */
storageKey: string
/** Delay before auto-starting the tour (ms) */
autoStartDelay?: number
/** Whether this tour can be reset/retriggered */
resettable?: boolean
/** Custom event name to listen for manual triggers */
triggerEvent?: string
/** Identifier for logging */
tourName?: string
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
disabled?: boolean
}
@@ -35,14 +41,49 @@ interface UseTourReturn {
handleCallback: (data: CallBackProps) => void
}
function isTourCompleted(storageKey: string): boolean {
try {
return localStorage.getItem(storageKey) === 'true'
} catch {
return false
}
}
function markTourCompleted(storageKey: string): void {
try {
localStorage.setItem(storageKey, 'true')
} catch {
logger.warn('Failed to persist tour completion', { storageKey })
}
}
function clearTourCompletion(storageKey: string): void {
try {
localStorage.removeItem(storageKey)
} catch {
logger.warn('Failed to clear tour completion', { storageKey })
}
}
/**
* Tracks which tours have already attempted auto-start in this page session.
* Module-level so it survives component remounts (e.g. navigating between
* workflows remounts WorkflowTour), while still resetting on full page reload.
*/
const autoStartAttempted = new Set<string>()
/**
* Shared hook for managing product tour state with smooth transitions.
*
* Handles manual triggering via custom events and coordinated fade
* Handles auto-start on first visit, localStorage persistence,
* manual triggering via custom events, and coordinated fade
* transitions between steps to prevent layout shift.
*/
export function useTour({
steps,
storageKey,
autoStartDelay = 1200,
resettable = false,
triggerEvent,
tourName = 'tour',
disabled = false,
@@ -53,10 +94,15 @@ export function useTour({
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
const [isEntrance, setIsEntrance] = useState(true)
const disabledRef = useRef(disabled)
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
/**
* Schedules a two-frame rAF to reveal the tooltip after the browser
* finishes repositioning. Stores the outer frame ID in `rafRef` so
@@ -91,7 +137,8 @@ export function useTour({
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
}, [cancelPendingTransitions])
markTourCompleted(storageKey)
}, [storageKey, cancelPendingTransitions])
/** Transition to a new step with a coordinated fade-out/fade-in */
const transitionToStep = useCallback(
@@ -117,17 +164,40 @@ export function useTour({
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
useEffect(() => {
if (disabled && run) {
stopTour()
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
logger.info(`${tourName} paused — disabled became true`)
}
}, [disabled, run, tourName, stopTour])
}, [disabled, run, tourName, cancelPendingTransitions])
/** Auto-start on first visit (once per page session per tour) */
useEffect(() => {
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
const timer = setTimeout(() => {
if (disabledRef.current) return
autoStartAttempted.add(storageKey)
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`Auto-starting ${tourName}`)
scheduleReveal()
}, autoStartDelay)
return () => clearTimeout(timer)
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
/** Listen for manual trigger events */
useEffect(() => {
if (!triggerEvent) return
if (!triggerEvent || !resettable) return
const handleTrigger = () => {
setRun(false)
clearTourCompletion(storageKey)
setTourKey((k) => k + 1)
if (retriggerTimerRef.current) {
@@ -152,7 +222,7 @@ export function useTour({
clearTimeout(retriggerTimerRef.current)
}
}
}, [triggerEvent, tourName, scheduleReveal])
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
/** Clean up all pending async work on unmount */
useEffect(() => {

View File

@@ -15,15 +15,19 @@ const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
/**
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
* Triggered via "Take a tour" in the sidebar menu.
* Runs on first workflow visit and can be retriggered via "Take a tour".
*/
export function WorkflowTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: workflowTourSteps,
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
autoStartDelay: 800,
resettable: true,
triggerEvent: START_WORKFLOW_TOUR_EVENT,
tourName: 'Workflow tour',
})

View File

@@ -290,16 +290,6 @@ function TextEditor({
}
}, [isResizing])
const handleCheckboxToggle = useCallback(
(checkboxIndex: number, checked: boolean) => {
const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked)
if (toggled !== contentRef.current) {
handleContentChange(toggled)
}
},
[handleContentChange]
)
const isStreaming = streamingContent !== undefined
const revealedContent = useStreamingText(content, isStreaming)
@@ -402,11 +392,10 @@ function TextEditor({
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
>
<PreviewPanel
content={isStreaming ? revealedContent : content}
content={revealedContent}
mimeType={file.type}
filename={file.name}
isStreaming={isStreaming}
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
/>
</div>
</>
@@ -714,14 +703,6 @@ function PptxPreview({
)
}
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
let currentIndex = 0
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
if (currentIndex++ !== targetIndex) return match
return `${prefix}[${checked ? 'x' : ' '}]`
})
}
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {

View File

@@ -1,10 +1,9 @@
'use client'
import { memo, useMemo, useRef } from 'react'
import { memo, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { Checkbox } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
@@ -41,7 +40,6 @@ interface PreviewPanelProps {
mimeType: string | null
filename: string
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}
export const PreviewPanel = memo(function PreviewPanel({
@@ -49,18 +47,11 @@ export const PreviewPanel = memo(function PreviewPanel({
mimeType,
filename,
isStreaming,
onCheckboxToggle,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'markdown')
return (
<MarkdownPreview
content={content}
isStreaming={isStreaming}
onCheckboxToggle={onCheckboxToggle}
/>
)
return <MarkdownPreview content={content} isStreaming={isStreaming} />
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'svg') return <SvgPreview content={content} />
@@ -70,7 +61,7 @@ export const PreviewPanel = memo(function PreviewPanel({
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
const STATIC_MARKDOWN_COMPONENTS = {
const PREVIEW_MARKDOWN_COMPONENTS = {
p: ({ children }: any) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
@@ -96,6 +87,17 @@ const STATIC_MARKDOWN_COMPONENTS = {
{children}
</h4>
),
ul: ({ children }: any) => (
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ul>
),
ol: ({ children }: any) => (
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ol>
),
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
@@ -163,110 +165,26 @@ const STATIC_MARKDOWN_COMPONENTS = {
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
}
function buildMarkdownComponents(
checkboxCounterRef: React.MutableRefObject<number>,
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
) {
const isInteractive = Boolean(onCheckboxToggle)
return {
...STATIC_MARKDOWN_COMPONENTS,
ul: ({ className, children }: any) => {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ul
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
)}
>
{children}
</ul>
)
},
ol: ({ className, children }: any) => {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ol
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
)}
>
{children}
</ol>
)
},
li: ({ className, children }: any) => {
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
if (isTaskItem) {
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
}
return <li className='break-words leading-[1.6]'>{children}</li>
},
input: ({ type, checked, ...props }: any) => {
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
const index = checkboxCounterRef.current++
return (
<Checkbox
checked={checked ?? false}
onCheckedChange={
isInteractive
? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked))
: undefined
}
disabled={!isInteractive}
size='sm'
className='mt-1 shrink-0'
/>
)
},
}
}
const MarkdownPreview = memo(function MarkdownPreview({
content,
isStreaming = false,
onCheckboxToggle,
}: {
content: string
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}) {
const { ref: scrollRef } = useAutoScroll(isStreaming)
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
const checkboxCounterRef = useRef(0)
const components = useMemo(
() => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle),
[onCheckboxToggle]
)
checkboxCounterRef.current = 0
const committedMarkdown = useMemo(
() =>
committed ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
{committed}
</ReactMarkdown>
) : null,
[committed, components]
[committed]
)
if (onCheckboxToggle) {
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{content}
</ReactMarkdown>
</div>
)
}
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
{committedMarkdown}
@@ -275,7 +193,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
{incoming}
</ReactMarkdown>
</div>

View File

@@ -82,7 +82,6 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
create_job: Calendar,
manage_job: Calendar,
update_job_history: Calendar,
job_respond: Calendar,
// Management
manage_mcp_tool: Settings,
manage_skill: Asterisk,

View File

@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
@@ -72,16 +72,7 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
const DocIcon = getDocumentIcon('', item.name)
return (
<>
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
return (
<>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
@@ -113,7 +104,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
file: {
type: 'file',
@@ -132,7 +123,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<Database className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
} as const

View File

@@ -34,7 +34,7 @@ export type WindowWithSpeech = Window & {
}
export interface PlusMenuHandle {
open: (anchor?: { left: number; top: number }) => void
open: () => void
}
export const TEXTAREA_BASE_CLASSES = cn(

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Paperclip } from 'lucide-react'
import {
DropdownMenu,
@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus, Sim } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
@@ -36,24 +37,24 @@ export const PlusMenuDropdown = React.memo(
) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const activeIndexRef = useRef(activeIndex)
const doOpen = useCallback((anchor?: { left: number; top: number }) => {
if (anchor) {
setAnchorPos(anchor)
} else {
const rect = buttonRef.current?.getBoundingClientRect()
if (!rect) return
setAnchorPos({ left: rect.left, top: rect.top })
}
setOpen(true)
setSearch('')
}, [])
useEffect(() => {
activeIndexRef.current = activeIndex
}, [activeIndex])
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
React.useImperativeHandle(
ref,
() => ({
open: () => {
setOpen(true)
setSearch('')
setActiveIndex(0)
},
}),
[]
)
const filteredItems = useMemo(() => {
const q = search.toLowerCase().trim()
@@ -68,6 +69,7 @@ export const PlusMenuDropdown = React.memo(
onResourceSelect(resource)
setOpen(false)
setSearch('')
setActiveIndex(0)
},
[onResourceSelect]
)
@@ -77,37 +79,32 @@ export const PlusMenuDropdown = React.memo(
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
const items = filteredItemsRef.current
if (!items) return
if (e.key === 'ArrowDown') {
e.preventDefault()
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
firstItem?.focus()
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
const first = filteredItemsRef.current?.[0]
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
const idx = activeIndexRef.current
if (items.length > 0 && items[idx]) {
const { type, item } = items[idx]
handleSelect({ type, id: item.id, title: item.name })
}
}
},
[handleSelect]
)
const handleContentKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowUp') {
const items = Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
)
if (items[0] && items[0] === document.activeElement) {
e.preventDefault()
searchRef.current?.focus()
}
}
}, [])
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
setSearch('')
setAnchorPos(null)
setActiveIndex(0)
onClose()
}
},
@@ -129,138 +126,126 @@ export const PlusMenuDropdown = React.memo(
)
return (
<>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: anchorPos?.left ?? 0,
top: anchorPos?.top ?? 0,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
align='start'
side='top'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
onKeyDown={handleContentKeyDown}
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Add attachments or resources'
>
<DropdownMenuSearchInput
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
{config.label}
</span>
</DropdownMenuItem>
)
})
) : (
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableResources.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<button
ref={buttonRef}
type='button'
onClick={() => doOpen()}
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Add attachments or resources'
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='top'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
>
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</>
<DropdownMenuSearchInput
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
{config.label}
</span>
</DropdownMenuItem>
)
})
) : (
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableResources.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
})
)

View File

@@ -50,50 +50,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
function getCaretAnchor(
textarea: HTMLTextAreaElement,
caretPos: number
): { left: number; top: number } {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const mirror = document.createElement('div')
mirror.style.position = 'absolute'
mirror.style.top = '0'
mirror.style.left = '0'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.overflowWrap = 'break-word'
mirror.style.font = style.font
mirror.style.padding = style.padding
mirror.style.border = style.border
mirror.style.width = style.width
mirror.style.lineHeight = style.lineHeight
mirror.style.boxSizing = style.boxSizing
mirror.style.letterSpacing = style.letterSpacing
mirror.style.textTransform = style.textTransform
mirror.style.textIndent = style.textIndent
mirror.style.textAlign = style.textAlign
mirror.textContent = textarea.value.substring(0, caretPos)
const marker = document.createElement('span')
marker.style.display = 'inline-block'
marker.style.width = '0px'
marker.style.padding = '0'
marker.style.border = '0'
mirror.appendChild(marker)
document.body.appendChild(mirror)
const markerRect = marker.getBoundingClientRect()
const mirrorRect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
return {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop,
}
}
interface UserInputProps {
defaultValue?: string
editValue?: string
@@ -530,8 +486,7 @@ export function UserInput({
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
plusMenuRef.current?.open()
restartRecognition(adjusted)
return
}
@@ -567,28 +522,6 @@ export function UserInput({
[isInitialView]
)
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length === 0) return
e.preventDefault()
const dt = new DataTransfer()
for (const file of imageFiles) {
dt.items.add(file)
}
filesRef.current.processFiles(dt.files)
}, [])
const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
if (overlayRef.current) {
overlayRef.current.scrollTop = e.currentTarget.scrollTop
@@ -728,7 +661,6 @@ export function UserInput({
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onInput={handleInput}
onPaste={handlePaste}
onCut={mentionTokensWithContext.handleCut}
onSelect={handleSelectAdjust}
onMouseUp={handleSelectAdjust}

View File

@@ -54,7 +54,6 @@ export function Home({ chatId }: HomeProps = {}) {
description,
color,
workspaceId,
deduplicate: true,
}),
})

View File

@@ -98,7 +98,6 @@ export type MothershipToolName =
| 'create_job'
| 'complete_job'
| 'update_job_history'
| 'job_respond'
| 'download_to_workspace_file'
| 'materialize_file'
| 'context_write'
@@ -394,7 +393,6 @@ export const TOOL_UI_METADATA: Record<MothershipToolName, ToolUIMetadata> = {
create_job: { title: 'Creating job', phaseLabel: 'Resource', phase: 'resource' },
manage_job: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
update_job_history: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
job_respond: { title: 'Explaining job scheduled', phaseLabel: 'Execution', phase: 'execution' },
// Management
manage_mcp_tool: { title: 'Updating integration', phaseLabel: 'Management', phase: 'management' },
manage_skill: { title: 'Updating skill', phaseLabel: 'Management', phase: 'management' },

View File

@@ -373,7 +373,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<div className='flex flex-col gap-3'>
{/* Auth: API key input or OAuth credential selection */}
{isApiKeyMode ? (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<Label>
{connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.label
? connectorConfig.auth.label
@@ -394,7 +394,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
/>
</div>
) : (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<Label>Account</Label>
{credentialsLoading ? (
<div className='flex items-center gap-2 text-[var(--text-muted)] text-small'>
@@ -442,7 +442,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
return (
<div key={field.id} className='flex flex-col gap-2'>
<div key={field.id} className='flex flex-col gap-1'>
<div className='flex items-center justify-between'>
<Label>
{field.title}
@@ -507,7 +507,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
{/* Tag definitions (opt-out) */}
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<Label>Metadata Tags</Label>
{connectorConfig.tagDefinitions.map((tagDef) => (
<div
@@ -550,7 +550,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
)}
{/* Sync interval */}
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<Label>Sync Frequency</Label>
<ButtonGroup
value={String(syncInterval)}

View File

@@ -18,7 +18,6 @@ import {
import {
Badge,
Button,
Checkbox,
Modal,
ModalBody,
ModalContent,
@@ -78,12 +77,6 @@ export function ConnectorsSection({
const { mutate: updateConnector } = useUpdateConnector()
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [deleteDocuments, setDeleteDocuments] = useState(false)
const closeDeleteModal = useCallback(() => {
setDeleteTarget(null)
setDeleteDocuments(false)
}, [])
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
const [error, setError] = useState<string | null>(null)
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
@@ -231,30 +224,22 @@ export function ConnectorsSection({
/>
)}
<Modal open={deleteTarget !== null} onOpenChange={closeDeleteModal}>
<Modal open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
<ModalContent size='sm'>
<ModalHeader>Remove Connector</ModalHeader>
<ModalHeader>Delete Connector</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-sm'>
This will disconnect the source and stop future syncs. Documents already synced will
remain in the knowledge base unless you choose to delete them.
Are you sure you want to remove this connected source?{' '}
<span className='text-[var(--text-error)]'>
This will stop future syncs from this source.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
Documents already synced will remain in the knowledge base.
</span>
</p>
<div className='mt-3 flex items-center gap-2'>
<Checkbox
id='delete-docs'
checked={deleteDocuments}
onCheckedChange={(checked) => setDeleteDocuments(checked === true)}
/>
<label
htmlFor='delete-docs'
className='cursor-pointer text-[var(--text-secondary)] text-sm'
>
Also delete all synced documents
</label>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={closeDeleteModal} disabled={isDeleting}>
<Button variant='default' onClick={() => setDeleteTarget(null)} disabled={isDeleting}>
Cancel
</Button>
<Button
@@ -263,23 +248,23 @@ export function ConnectorsSection({
onClick={() => {
if (deleteTarget) {
deleteConnector(
{ knowledgeBaseId, connectorId: deleteTarget, deleteDocuments },
{ knowledgeBaseId, connectorId: deleteTarget },
{
onSuccess: () => {
setError(null)
closeDeleteModal()
setDeleteTarget(null)
},
onError: (err) => {
logger.error('Delete connector failed', { error: err.message })
setError(err.message)
closeDeleteModal()
setDeleteTarget(null)
},
}
)
}
}}
>
{isDeleting ? 'Removing...' : 'Remove'}
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -198,7 +198,7 @@ function SettingsTab({
return (
<div className='flex flex-col gap-3'>
{connectorConfig?.configFields.map((field) => (
<div key={field.id} className='flex flex-col gap-2'>
<div key={field.id} className='flex flex-col gap-1'>
<Label>
{field.title}
{field.required && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
@@ -227,7 +227,7 @@ function SettingsTab({
</div>
))}
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<Label>Sync Frequency</Label>
<ButtonGroup
value={String(syncInterval)}

View File

@@ -57,26 +57,14 @@ function parseShortcut(shortcut: string): ParsedShortcut {
}
}
/**
* Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions.
* Needed for international keyboard layouts where e.key may produce unexpected characters
* (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead').
*/
function codeToKey(code: string): string | undefined {
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
if (code.startsWith('Digit')) return code.slice(5)
return undefined
}
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key
return (
keyMatches &&
eventKey === parsed.key &&
!!e.ctrlKey === !!expectedCtrl &&
!!e.metaKey === !!expectedMeta &&
!!e.shiftKey === !!parsed.shift &&

View File

@@ -203,35 +203,3 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
const { userPermissions } = useWorkspacePermissionsContext()
return userPermissions
}
/**
* Lightweight permissions provider for sandbox/academy contexts.
* Grants full edit access without any API calls or workspace dependencies.
*/
export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
const sandboxPermissions = useMemo(
(): WorkspacePermissionsContextType => ({
workspacePermissions: null,
permissionsLoading: false,
permissionsError: null,
updatePermissions: () => {},
refetchPermissions: async () => {},
userPermissions: {
canRead: true,
canEdit: true,
canAdmin: false,
userPermissions: 'write',
isLoading: false,
error: null,
isOfflineMode: false,
},
}),
[]
)
return (
<WorkspacePermissionsContext.Provider value={sandboxPermissions}>
{children}
</WorkspacePermissionsContext.Provider>
)
}

View File

@@ -1,162 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Combobox,
type ComboboxOption,
Input as EmcnInput,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { FormField } from '@/app/workspace/[workspaceId]/settings/components/mcp/components'
import { useCreateWorkflowMcpServer } from '@/hooks/queries/workflow-mcp-servers'
const logger = createLogger('CreateWorkflowMcpServerModal')
const INITIAL_FORM_DATA: { name: string; description: string; isPublic: boolean } = {
name: '',
description: '',
isPublic: false,
}
interface CreateWorkflowMcpServerModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
workflowOptions?: ComboboxOption[]
isLoadingWorkflows?: boolean
}
export function CreateWorkflowMcpServerModal({
open,
onOpenChange,
workspaceId,
workflowOptions,
isLoadingWorkflows = false,
}: CreateWorkflowMcpServerModalProps) {
const createServerMutation = useCreateWorkflowMcpServer()
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const isFormValid = formData.name.trim().length > 0
useEffect(() => {
if (open) {
setFormData({ ...INITIAL_FORM_DATA })
setSelectedWorkflowIds([])
}
}, [open])
const handleCreateServer = useCallback(async () => {
if (!formData.name.trim()) return
try {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
onOpenChange(false)
} catch (err) {
logger.error('Failed to create server:', err)
}
}, [formData, selectedWorkflowIds, workspaceId, onOpenChange])
const showWorkflows = workflowOptions !== undefined
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Add New MCP Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-3'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
{showWorkflows && (
<FormField label='Workflows'>
<Combobox
options={workflowOptions ?? []}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
)}
<FormField label='Access'>
<div className='flex items-center gap-3'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[var(--text-muted)] text-xs'>
No authentication required
</span>
)}
</div>
</FormField>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateServer}
disabled={!isFormValid || createServerMutation.isPending}
variant='primary'
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -35,6 +35,7 @@ import { useApiKeys } from '@/hooks/queries/api-keys'
import { useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
useDeleteWorkflowMcpServer,
useDeleteWorkflowMcpTool,
useDeployedWorkflows,
@@ -48,7 +49,6 @@ import {
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from '../api-keys/components'
import { FormField, McpServerSkeleton } from '../mcp/components'
import { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'
const logger = createLogger('WorkflowMcpServers')
@@ -955,10 +955,13 @@ export function WorkflowMcpServers() {
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const createServerMutation = useCreateWorkflowMcpServer()
const deleteServerMutation = useDeleteWorkflowMcpServer()
const [searchTerm, setSearchTerm] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
@@ -976,6 +979,29 @@ export function WorkflowMcpServers() {
}))
}, [deployedWorkflows])
const resetForm = useCallback(() => {
setFormData({ name: '', description: '', isPublic: false })
setSelectedWorkflowIds([])
setShowAddModal(false)
}, [])
const handleCreateServer = async () => {
if (!formData.name.trim()) return
try {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
resetForm()
} catch (err) {
logger.error('Failed to create server:', err)
}
}
const handleDeleteServer = async () => {
if (!serverToDelete) return
@@ -1000,6 +1026,7 @@ export function WorkflowMcpServers() {
const hasServers = servers.length > 0
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
const isFormValid = formData.name.trim().length > 0
if (selectedServerId) {
return (
@@ -1096,13 +1123,86 @@ export function WorkflowMcpServers() {
</div>
</div>
<CreateWorkflowMcpServerModal
open={showAddModal}
onOpenChange={setShowAddModal}
workspaceId={workspaceId}
workflowOptions={workflowOptions}
isLoadingWorkflows={isLoadingWorkflows}
/>
<Modal open={showAddModal} onOpenChange={(open) => !open && resetForm()}>
<ModalContent>
<ModalHeader>Add New MCP Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-3'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Workflows'>
<Combobox
options={workflowOptions}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
<FormField label='Access'>
<div className='flex items-center gap-3'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[var(--text-muted)] text-xs'>
No authentication required
</span>
)}
</div>
</FormField>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={resetForm}>
Cancel
</Button>
<Button
onClick={handleCreateServer}
disabled={!isFormValid || createServerMutation.isPending}
variant='primary'
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
<ModalContent size='sm'>

View File

@@ -9,8 +9,6 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
export type CommandId =
| 'accept-diff-changes'
| 'add-agent'
| 'add-workflow'
| 'add-task'
// | 'goto-templates'
| 'goto-logs'
| 'open-search'
@@ -54,16 +52,6 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
shortcut: 'Mod+Shift+A',
allowInEditable: true,
},
'add-workflow': {
id: 'add-workflow',
shortcut: 'Mod+Shift+P',
allowInEditable: false,
},
'add-task': {
id: 'add-task',
shortcut: 'Mod+Shift+K',
allowInEditable: false,
},
// 'goto-templates': {
// id: 'goto-templates',
// shortcut: 'Mod+Y',

View File

@@ -320,6 +320,5 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
handleDragOver,
handleDrop,
clearAttachedFiles,
processFiles,
}
}

View File

@@ -17,7 +17,8 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/create-workflow-mcp-server-modal'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useDeleteWorkflowMcpTool,
@@ -27,6 +28,7 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -100,7 +102,11 @@ export function McpDeploy({
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [showCreateModal, setShowCreateModal] = useState(false)
const [showMcpModal, setShowMcpModal] = useState(false)
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
@@ -467,16 +473,22 @@ export function McpDeploy({
<>
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server to expose your workflows as tools.
Create an MCP Server in Settings MCP Servers first.
</p>
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
Create MCP Server
</Button>
</div>
<CreateWorkflowMcpServerModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</>
)

View File

@@ -28,7 +28,6 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import type { BlockConfig } from '@/blocks/types'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints'
import { useToolbarStore } from '@/stores/panel'
interface BlockItem {
@@ -349,20 +348,12 @@ export const Toolbar = memo(
})
const { filterBlocks } = usePermissionConfig()
const sandboxAllowedBlocks = useSandboxBlockConstraints()
const allTriggers = getTriggers()
const allBlocks = getBlocks()
const blocks = useMemo(() => {
const permitted = filterBlocks(allBlocks)
if (sandboxAllowedBlocks === null) return permitted
return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type))
}, [filterBlocks, allBlocks, sandboxAllowedBlocks])
const triggers = useMemo(() => {
if (sandboxAllowedBlocks !== null) return []
return filterBlocks(allTriggers)
}, [filterBlocks, allTriggers, sandboxAllowedBlocks])
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD

View File

@@ -89,15 +89,10 @@ const logger = createLogger('Panel')
*
* @returns Panel on the right side of the workflow
*/
interface PanelProps {
/** Override workspaceId when rendered outside a workspace route (e.g. sandbox mode) */
workspaceId?: string
}
export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) {
export const Panel = memo(function Panel() {
const router = useRouter()
const params = useParams()
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
const workspaceId = params.workspaceId as string
const panelRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)

View File

@@ -14,8 +14,6 @@ export interface WorkflowBlockProps {
isPreviewSelected?: boolean
/** Whether this block is rendered inside an embedded (read-only) workflow view */
isEmbedded?: boolean
/** Whether this block is rendered inside a sandbox (academy exercise) view with no workspace API calls */
isSandbox?: boolean
subBlockValues?: Record<string, any>
blockState?: any
}

View File

@@ -855,14 +855,13 @@ export const WorkflowBlock = memo(function WorkflowBlock({
data,
selected,
}: NodeProps<WorkflowBlockProps>) {
const { type, config, name, isPending, isSandbox } = data
const { type, config, name, isPending } = data
const contentRef = useRef<HTMLDivElement>(null)
const params = useParams()
// In sandbox mode pass empty strings so all workspace-scoped queries are disabled
const currentWorkflowId = isSandbox ? '' : (params.workflowId as string)
const workspaceId = isSandbox ? '' : (params.workspaceId as string)
const currentWorkflowId = params.workflowId as string
const workspaceId = params.workspaceId as string
const {
currentWorkflow,

View File

@@ -380,13 +380,6 @@ export function useWorkflowExecution() {
async (workflowInput?: any, enableDebug = false) => {
if (!activeWorkflowId) return
// Sandbox exercises have no real workflow — signal the SandboxCanvasProvider
// to run mock execution by setting isExecuting, then bail out immediately.
if (workflows[activeWorkflowId]?.isSandbox) {
setIsExecuting(activeWorkflowId, true)
return
}
// Get workspaceId from workflow metadata
const workspaceId = workflows[activeWorkflowId]?.workspaceId

View File

@@ -231,8 +231,6 @@ interface WorkflowContentProps {
workspaceId?: string
workflowId?: string
embedded?: boolean
/** Sandbox mode: full editing enabled but no workspace API calls (used by Sim Academy). */
sandbox?: boolean
}
const WorkflowContent = React.memo(
@@ -240,7 +238,6 @@ const WorkflowContent = React.memo(
workspaceId: propWorkspaceId,
workflowId: propWorkflowId,
embedded,
sandbox,
}: WorkflowContentProps = {}) => {
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
@@ -330,7 +327,7 @@ const WorkflowContent = React.memo(
const snapToGridSize = useSnapToGridSize()
const snapToGrid = snapToGridSize > 0
const isAutoConnectEnabled = useAutoConnect() && !sandbox
const isAutoConnectEnabled = useAutoConnect()
const autoConnectRef = useRef(isAutoConnectEnabled)
autoConnectRef.current = isAutoConnectEnabled
@@ -1239,7 +1236,7 @@ const WorkflowContent = React.memo(
clearLockNotification()
}
if (allBlocksLocked && !sandbox) {
if (allBlocksLocked) {
if (lockNotificationIdRef.current) return
const isAdmin = effectivePermissions.canAdmin
@@ -2195,9 +2192,6 @@ const WorkflowContent = React.memo(
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
useEffect(() => {
// In sandbox mode the stores are pre-hydrated externally; skip the API load.
if (sandbox) return
const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId
@@ -2266,13 +2260,13 @@ const WorkflowContent = React.memo(
workspaceId,
])
useWorkspaceEnvironment(sandbox ? '' : workspaceId)
useWorkspaceEnvironment(workspaceId)
const workflowCount = useMemo(() => Object.keys(workflows).length, [workflows])
/** Handles navigation validation and redirects for invalid workflow IDs. */
useEffect(() => {
if (embedded || sandbox) return
if (embedded) return
// Wait for metadata to finish loading before making navigation decisions
if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') {
@@ -2457,7 +2451,6 @@ const WorkflowContent = React.memo(
isActive,
isPending,
...(embedded && { isEmbedded: true }),
...(sandbox && { isSandbox: true }),
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
@@ -2470,16 +2463,7 @@ const WorkflowContent = React.memo(
})
return nodeArray
}, [
blocksStructureHash,
blocks,
activeBlockIds,
pendingBlocks,
isDebugging,
getBlockConfig,
sandbox,
embedded,
])
}, [blocksStructureHash, blocks, activeBlockIds, pendingBlocks, isDebugging, getBlockConfig])
// Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
@@ -4112,9 +4096,9 @@ const WorkflowContent = React.memo(
<Terminal />
</div>
{(!embedded || sandbox) && <Panel workspaceId={sandbox ? workspaceId : undefined} />}
{!embedded && <Panel />}
{!embedded && !sandbox && oauthModal && (
{!embedded && oauthModal && (
<Suspense fallback={null}>
<LazyOAuthRequiredModal
isOpen={true}
@@ -4138,27 +4122,18 @@ interface WorkflowProps {
workspaceId?: string
workflowId?: string
embedded?: boolean
/** Sandbox mode: full editing enabled but no workspace API calls (used by Sim Academy). */
sandbox?: boolean
}
/** Workflow page with ReactFlowProvider and error boundary wrapper. */
const Workflow = React.memo(
({ workspaceId, workflowId, embedded, sandbox }: WorkflowProps = {}) => {
return (
<ReactFlowProvider>
<ErrorBoundary>
<WorkflowContent
workspaceId={workspaceId}
workflowId={workflowId}
embedded={embedded}
sandbox={sandbox}
/>
</ErrorBoundary>
</ReactFlowProvider>
)
}
)
const Workflow = React.memo(({ workspaceId, workflowId, embedded }: WorkflowProps = {}) => {
return (
<ReactFlowProvider>
<ErrorBoundary>
<WorkflowContent workspaceId={workspaceId} workflowId={workflowId} embedded={embedded} />
</ErrorBoundary>
</ReactFlowProvider>
)
})
Workflow.displayName = 'Workflow'

View File

@@ -128,7 +128,7 @@ function TaskStatusIcon({
function WorkflowColorSwatch({ color }: { color: string }) {
return (
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-sm border-[2.5px]'
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
@@ -161,7 +161,7 @@ export function CollapsedSidebarMenu({
<button
type='button'
aria-label={ariaLabel}
className='mx-0.5 flex h-[30px] items-center rounded-lg px-2 hover-hover:bg-[var(--surface-hover)]'
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover-hover:bg-[var(--surface-hover)]'
>
{icon}
</button>

View File

@@ -343,7 +343,7 @@ export function SearchModal({
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]',
open ? 'visible opacity-100' : 'invisible opacity-0'
)}
style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }}
style={{ left: '50%' }}
>
<Command label='Search' shouldFilter={false}>
<div className='mx-2 mt-2 mb-1 flex items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>

View File

@@ -210,7 +210,7 @@ export function SettingsSidebar({
{/* Settings sections */}
<div
className={cn(
'mt-3.5 flex flex-1 flex-col gap-3.5 pb-2',
'mt-3.5 flex flex-1 flex-col gap-3.5',
!isCollapsed && 'overflow-y-auto overflow-x-hidden'
)}
>

View File

@@ -343,7 +343,7 @@ export function WorkspaceHeader({
type='button'
aria-label='Switch workspace'
className={cn(
'group flex h-[32px] min-w-0 items-center rounded-lg border border-[var(--border)] bg-[var(--surface-2)] pl-[5px] transition-colors hover-hover:bg-[var(--surface-5)]',
'group flex h-[32px] min-w-0 items-center rounded-lg border border-[var(--border)] bg-[var(--surface-2)] pl-1.5 transition-colors hover-hover:bg-[var(--surface-5)]',
isCollapsed ? 'w-[32px]' : 'w-full cursor-pointer gap-2 pr-2'
)}
title={activeWorkspace?.name || 'Loading...'}

View File

@@ -56,7 +56,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
workspaceId,
folderId,
sortOrder,
deduplicate: true,
}),
})

View File

@@ -176,7 +176,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
color: workflowColor,
workspaceId: newWorkspace.id,
folderId: targetFolderId,
deduplicate: true,
}),
})

View File

@@ -303,7 +303,6 @@ async function runWorkflowExecution({
export type ScheduleExecutionPayload = {
scheduleId: string
workflowId: string
workspaceId?: string
executionId?: string
requestId?: string
correlation?: AsyncExecutionCorrelation

View File

@@ -36,7 +36,6 @@ export function buildWorkflowCorrelation(
export type WorkflowExecutionPayload = {
workflowId: string
userId: string
workspaceId?: string
input?: any
triggerType?: CoreTriggerType
executionId?: string

View File

@@ -1,5 +1,5 @@
import { createHmac } from 'crypto'
import { db, workflowExecutionLogs } from '@sim/db'
import { db } from '@sim/db'
import {
account,
workspaceNotificationDelivery,
@@ -17,14 +17,11 @@ import {
import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
import { acquireLock } from '@/lib/core/config/redis'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
import type { AlertConfig } from '@/lib/notifications/alert-rules'
@@ -35,7 +32,6 @@ const logger = createLogger('WorkspaceNotificationDelivery')
const MAX_ATTEMPTS = 5
const RETRY_DELAYS = [5 * 1000, 15 * 1000, 60 * 1000, 3 * 60 * 1000, 10 * 60 * 1000]
const NOTIFICATION_DISPATCH_LOCK_TTL_SECONDS = 3
function getRetryDelayWithJitter(baseDelay: number): number {
const jitter = Math.random() * 0.1 * baseDelay
@@ -490,170 +486,12 @@ async function updateDeliveryStatus(
export interface NotificationDeliveryParams {
deliveryId: string
subscriptionId: string
workspaceId: string
notificationType: 'webhook' | 'email' | 'slack'
log: WorkflowExecutionLog
alertConfig?: AlertConfig
}
export type NotificationDeliveryResult =
| { status: 'success' | 'skipped' | 'failed' }
| { status: 'retry'; retryDelayMs: number }
async function buildRetryLog(params: NotificationDeliveryParams): Promise<WorkflowExecutionLog> {
const conditions = [eq(workflowExecutionLogs.executionId, params.log.executionId)]
if (params.log.workflowId) {
conditions.push(eq(workflowExecutionLogs.workflowId, params.log.workflowId))
}
const [storedLog] = await db
.select()
.from(workflowExecutionLogs)
.where(and(...conditions))
.limit(1)
if (storedLog) {
return storedLog as unknown as WorkflowExecutionLog
}
const now = new Date().toISOString()
return {
id: `retry_log_${params.deliveryId}`,
workflowId: params.log.workflowId,
executionId: params.log.executionId,
stateSnapshotId: '',
level: 'info',
trigger: 'system',
startedAt: now,
endedAt: now,
totalDurationMs: 0,
executionData: {},
cost: { total: 0 },
createdAt: now,
}
}
export async function enqueueNotificationDeliveryDispatch(
params: NotificationDeliveryParams
): Promise<boolean> {
if (!isBullMQEnabled()) {
return false
}
const lockAcquired = await acquireLock(
`workspace-notification-dispatch:${params.deliveryId}`,
params.deliveryId,
NOTIFICATION_DISPATCH_LOCK_TTL_SECONDS
)
if (!lockAcquired) {
return false
}
await enqueueWorkspaceDispatch({
workspaceId: params.workspaceId,
lane: 'lightweight',
queueName: 'workspace-notification-delivery',
bullmqJobName: 'workspace-notification-delivery',
bullmqPayload: createBullMQJobData(params),
metadata: {
workflowId: params.log.workflowId ?? undefined,
},
})
return true
}
const STUCK_IN_PROGRESS_THRESHOLD_MS = 5 * 60 * 1000
export async function sweepPendingNotificationDeliveries(limit = 50): Promise<number> {
if (!isBullMQEnabled()) {
return 0
}
const stuckThreshold = new Date(Date.now() - STUCK_IN_PROGRESS_THRESHOLD_MS)
await db
.update(workspaceNotificationDelivery)
.set({
status: 'pending',
updatedAt: new Date(),
})
.where(
and(
eq(workspaceNotificationDelivery.status, 'in_progress'),
lte(workspaceNotificationDelivery.lastAttemptAt, stuckThreshold)
)
)
const dueDeliveries = await db
.select({
deliveryId: workspaceNotificationDelivery.id,
subscriptionId: workspaceNotificationDelivery.subscriptionId,
workflowId: workspaceNotificationDelivery.workflowId,
executionId: workspaceNotificationDelivery.executionId,
workspaceId: workspaceNotificationSubscription.workspaceId,
alertConfig: workspaceNotificationSubscription.alertConfig,
notificationType: workspaceNotificationSubscription.notificationType,
})
.from(workspaceNotificationDelivery)
.innerJoin(
workspaceNotificationSubscription,
eq(workspaceNotificationDelivery.subscriptionId, workspaceNotificationSubscription.id)
)
.where(
and(
eq(workspaceNotificationDelivery.status, 'pending'),
or(
isNull(workspaceNotificationDelivery.nextAttemptAt),
lte(workspaceNotificationDelivery.nextAttemptAt, new Date())
)
)
)
.limit(limit)
let enqueued = 0
for (const delivery of dueDeliveries) {
const params: NotificationDeliveryParams = {
deliveryId: delivery.deliveryId,
subscriptionId: delivery.subscriptionId,
workspaceId: delivery.workspaceId,
notificationType: delivery.notificationType,
log: await buildRetryLog({
deliveryId: delivery.deliveryId,
subscriptionId: delivery.subscriptionId,
workspaceId: delivery.workspaceId,
notificationType: delivery.notificationType,
log: {
id: '',
workflowId: delivery.workflowId,
executionId: delivery.executionId,
stateSnapshotId: '',
level: 'info',
trigger: 'system',
startedAt: '',
endedAt: '',
totalDurationMs: 0,
executionData: {},
cost: { total: 0 },
createdAt: '',
},
alertConfig: (delivery.alertConfig as AlertConfig | null) ?? undefined,
}),
alertConfig: (delivery.alertConfig as AlertConfig | null) ?? undefined,
}
if (await enqueueNotificationDeliveryDispatch(params)) {
enqueued += 1
}
}
return enqueued
}
export async function executeNotificationDelivery(
params: NotificationDeliveryParams
): Promise<NotificationDeliveryResult> {
export async function executeNotificationDelivery(params: NotificationDeliveryParams) {
const { deliveryId, subscriptionId, notificationType, log, alertConfig } = params
try {
@@ -666,7 +504,7 @@ export async function executeNotificationDelivery(
if (!subscription || !subscription.active) {
logger.warn(`Subscription ${subscriptionId} not found or inactive`)
await updateDeliveryStatus(deliveryId, 'failed', 'Subscription not found or inactive')
return { status: 'failed' }
return
}
const claimed = await db
@@ -691,7 +529,7 @@ export async function executeNotificationDelivery(
if (claimed.length === 0) {
logger.info(`Delivery ${deliveryId} not claimable`)
return { status: 'skipped' }
return
}
const attempts = claimed[0].attempts
@@ -701,7 +539,7 @@ export async function executeNotificationDelivery(
if (!payload) {
await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was archived or deleted')
logger.info(`Skipping delivery ${deliveryId} - workflow was archived or deleted`)
return { status: 'failed' }
return
}
let result: { success: boolean; status?: number; error?: string }
@@ -723,35 +561,39 @@ export async function executeNotificationDelivery(
if (result.success) {
await updateDeliveryStatus(deliveryId, 'success', undefined, result.status)
logger.info(`${notificationType} notification delivered successfully`, { deliveryId })
return { status: 'success' }
}
if (attempts < MAX_ATTEMPTS) {
const retryDelay = getRetryDelayWithJitter(
RETRY_DELAYS[attempts - 1] || RETRY_DELAYS[RETRY_DELAYS.length - 1]
)
const nextAttemptAt = new Date(Date.now() + retryDelay)
} else {
if (attempts < MAX_ATTEMPTS) {
const retryDelay = getRetryDelayWithJitter(
RETRY_DELAYS[attempts - 1] || RETRY_DELAYS[RETRY_DELAYS.length - 1]
)
const nextAttemptAt = new Date(Date.now() + retryDelay)
await updateDeliveryStatus(deliveryId, 'pending', result.error, result.status, nextAttemptAt)
await updateDeliveryStatus(
deliveryId,
'pending',
result.error,
result.status,
nextAttemptAt
)
logger.info(
`${notificationType} notification failed, scheduled retry ${attempts}/${MAX_ATTEMPTS}`,
{
logger.info(
`${notificationType} notification failed, scheduled retry ${attempts}/${MAX_ATTEMPTS}`,
{
deliveryId,
error: result.error,
}
)
} else {
await updateDeliveryStatus(deliveryId, 'failed', result.error, result.status)
logger.error(`${notificationType} notification failed after ${MAX_ATTEMPTS} attempts`, {
deliveryId,
error: result.error,
}
)
return { status: 'retry', retryDelayMs: retryDelay }
})
}
}
await updateDeliveryStatus(deliveryId, 'failed', result.error, result.status)
logger.error(`${notificationType} notification failed after ${MAX_ATTEMPTS} attempts`, {
deliveryId,
error: result.error,
})
return { status: 'failed' }
} catch (error) {
logger.error('Notification delivery failed', { deliveryId, error })
await updateDeliveryStatus(deliveryId, 'failed', 'Internal error')
return { status: 'failed' }
}
}

View File

@@ -28,6 +28,7 @@ export const ImapBlock: BlockConfig = {
host: { type: 'string', description: 'IMAP server hostname' },
port: { type: 'string', description: 'IMAP server port' },
secure: { type: 'boolean', description: 'Use SSL/TLS encryption' },
rejectUnauthorized: { type: 'boolean', description: 'Verify TLS certificate' },
username: { type: 'string', description: 'Email username' },
password: { type: 'string', description: 'Email password' },
mailbox: { type: 'string', description: 'Mailbox to monitor' },

View File

@@ -1532,7 +1532,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
projectId: effectiveProjectId || undefined,
includeArchived: params.includeArchived,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_get_issue':
@@ -1599,7 +1599,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
teamId: effectiveTeamId,
includeArchived: params.includeArchived,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_add_label_to_issue':
@@ -1650,7 +1650,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
issueId: params.issueId.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_list_projects':
@@ -1659,7 +1659,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
teamId: effectiveTeamId,
includeArchived: params.includeArchived,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_get_project':
@@ -1714,7 +1714,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_get_viewer':
@@ -1725,7 +1725,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
teamId: effectiveTeamId,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_create_label':
@@ -1764,7 +1764,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
teamId: effectiveTeamId,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_create_workflow_state':
@@ -1795,7 +1795,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
teamId: effectiveTeamId,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_get_cycle':
@@ -1860,7 +1860,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
issueId: params.issueId.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_update_attachment':
@@ -1901,7 +1901,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
issueId: params.issueId.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_delete_issue_relation':
@@ -1927,7 +1927,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_create_project_update':
@@ -1949,14 +1949,14 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
projectId: effectiveProjectId,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_list_notifications':
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_update_notification':
@@ -1988,7 +1988,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
includeArchived: false,
}
@@ -2023,7 +2023,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
includeArchived: false,
}
@@ -2117,7 +2117,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
// Customer Tier Operations
@@ -2159,7 +2159,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
// Project Management Operations
@@ -2212,7 +2212,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
projectId: effectiveProjectId || undefined,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
case 'linear_add_label_to_project':
@@ -2277,7 +2277,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
projectId: params.projectIdForMilestone.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
// Project Status Operations
@@ -2328,7 +2328,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after?.trim() || undefined,
after: params.after,
}
default:

View File

@@ -1,7 +1,7 @@
{
"id": "emir",
"name": "Emir Karabeg",
"url": "https://x.com/emkara",
"xHandle": "emkara",
"url": "https://x.com/karabegemir",
"xHandle": "karabegemir",
"avatarUrl": "/blog/authors/emir.jpg"
}

View File

@@ -236,13 +236,7 @@ export class VariableResolver {
}
if (typeof resolved === 'string') {
const escaped = resolved
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
const escaped = resolved.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
return `'${escaped}'`
}
if (typeof resolved === 'object' && resolved !== null) {

View File

@@ -1,44 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { AcademyCertificate } from '@/lib/academy/types'
import { fetchJson } from '@/hooks/selectors/helpers'
export const academyKeys = {
all: ['academy'] as const,
certificates: () => [...academyKeys.all, 'certificate'] as const,
certificate: (courseId: string) => [...academyKeys.certificates(), courseId] as const,
}
async function fetchCourseCertificate(
courseId: string,
signal: AbortSignal
): Promise<AcademyCertificate | null> {
const data = await fetchJson<{ certificate: AcademyCertificate | null }>(
`/api/academy/certificates?courseId=${encodeURIComponent(courseId)}`,
{ signal }
)
return data.certificate
}
export function useCourseCertificate(courseId?: string) {
return useQuery({
queryKey: academyKeys.certificate(courseId ?? ''),
queryFn: ({ signal }) => fetchCourseCertificate(courseId as string, signal),
enabled: Boolean(courseId),
staleTime: 60 * 1000,
})
}
export function useIssueCertificate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (variables: { courseId: string; completedLessonIds: string[] }) =>
fetchJson<{ certificate: AcademyCertificate }>('/api/academy/certificates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables),
}).then((d) => d.certificate),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: academyKeys.certificate(variables.courseId) })
},
})
}

View File

@@ -224,16 +224,13 @@ export function useUpdateConnector() {
export interface DeleteConnectorParams {
knowledgeBaseId: string
connectorId: string
deleteDocuments?: boolean
}
async function deleteConnector({
knowledgeBaseId,
connectorId,
deleteDocuments,
}: DeleteConnectorParams): Promise<void> {
const base = `/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`
const response = await fetch(deleteDocuments ? `${base}?deleteDocuments=true` : base, {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`, {
method: 'DELETE',
})

View File

@@ -164,7 +164,6 @@ interface CreateWorkflowVariables {
folderId?: string | null
sortOrder?: number
id?: string
deduplicate?: boolean
}
interface CreateWorkflowResult {
@@ -301,8 +300,7 @@ export function useCreateWorkflow() {
return useMutation({
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } =
variables
const { workspaceId, name, description, color, folderId, sortOrder, id } = variables
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
@@ -317,7 +315,6 @@ export function useCreateWorkflow() {
workspaceId,
folderId: folderId || null,
sortOrder,
deduplicate,
}),
})

View File

@@ -1,13 +0,0 @@
import { createContext, useContext } from 'react'
/**
* Provides the list of block types the learner is allowed to add in a sandbox exercise.
* Null means no constraint (all blocks allowed — the default outside sandbox mode).
* An empty array means no blocks may be added (configure/connect pre-placed blocks only).
*/
export const SandboxBlockConstraintsContext = createContext<string[] | null>(null)
/** Returns the sandbox-allowed block types, or null if not in a sandbox context. */
export function useSandboxBlockConstraints(): string[] | null {
return useContext(SandboxBlockConstraintsContext)
}

View File

@@ -1,729 +0,0 @@
import type { Course } from '@/lib/academy/types'
/**
* Sim Foundations — the introductory partner certification course.
*
* IDs must never change after a learner has started the course.
* Lesson IDs are used as localStorage keys for completion tracking.
* The course ID is stored on the certificate record.
*/
export const simFoundations: Course = {
id: 'sim-foundations',
slug: 'sim-foundations',
title: 'Sim Foundations',
description:
'Master the core building blocks of Sim — the canvas, agents, data flow, control logic, and deployment — through hands-on interactive exercises on the real canvas.',
estimatedMinutes: 75,
modules: [
{
id: 'sim-foundations-m1',
title: 'The Canvas',
description: 'Get oriented with the Sim canvas and understand how workflows are structured.',
lessons: [
{
id: 'sim-foundations-m1-l1',
slug: 'what-is-sim',
title: 'What is Sim?',
lessonType: 'video',
description:
'A high-level look at what Sim is, the problems it solves, and what a real workflow looks like running end-to-end.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 180,
},
{
id: 'sim-foundations-m1-l2',
slug: 'canvas-tour',
title: 'The Canvas Tour',
lessonType: 'video',
description:
'A guided tour of the canvas: placing blocks, connecting them, using the panel, running workflows, and essential keyboard shortcuts.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 240,
},
{
id: 'sim-foundations-m1-l3',
slug: 'your-first-workflow',
title: 'Your First Workflow',
lessonType: 'exercise',
description: 'Place an Agent block on the canvas and wire it to the Starter.',
exerciseConfig: {
instructions:
"Every workflow starts with a Starter block. Drag an Agent block from the toolbar onto the canvas, then connect the Starter's output handle to the Agent's input handle. Once connected, click Run to see it execute.",
availableBlocks: ['agent'],
initialBlocks: [
{
id: 'starter-1',
type: 'starter',
position: { x: 120, y: 220 },
locked: true,
},
],
validationRules: [
{
type: 'block_exists',
blockType: 'agent',
label: 'Add an Agent block to the canvas',
},
{
type: 'edge_exists',
sourceType: 'starter',
targetType: 'agent',
label: 'Connect the Starter to the Agent',
},
],
hints: [
'Find the Agent block in the toolbar on the right side of the canvas.',
"Hover over the Starter block's right edge to reveal its output handle, then drag to the Agent block.",
],
mockOutputs: {
starter: { response: { result: 'Workflow started' }, delay: 200 },
agent: {
response: { content: "Hello! I'm your first Sim agent. How can I help?" },
delay: 1200,
},
},
},
},
{
id: 'sim-foundations-m1-l4',
slug: 'canvas-concepts',
title: 'Canvas Concepts',
lessonType: 'quiz',
description: 'Check your understanding of the canvas before moving on.',
quizConfig: {
passingScore: 75,
questions: [
{
type: 'multiple_choice',
question: 'What is the role of the Starter block in a workflow?',
options: [
'It stores data between workflow runs',
'It defines the trigger and initial input for a workflow',
'It connects to external APIs',
'It runs JavaScript code',
],
correctIndex: 1,
explanation:
'The Starter block is always the entry point. It defines how the workflow is triggered — manually, via API, via chat, or on a schedule — and what data is passed in as input.',
},
{
type: 'true_false',
question:
'A single block can have multiple outgoing connections to different blocks.',
correctAnswer: true,
explanation:
'Yes — blocks can fan out to multiple downstream blocks, which then run in parallel. This is how you split execution into multiple concurrent branches.',
},
{
type: 'multiple_choice',
question: 'What does connecting two blocks with an edge do?',
options: [
'The second block runs immediately, regardless of the first',
"Data flows from the source block's output to the target block's input",
'Both blocks are merged into one',
'The first block is disabled',
],
correctIndex: 1,
explanation:
"Edges define data flow. When a block completes, its output is passed downstream to every connected block. The target block won't start until its source has finished.",
},
{
type: 'multiple_choice',
question: 'Which keyboard shortcut copies a selected block on the canvas?',
options: [
'Ctrl/Cmd + D',
'Ctrl/Cmd + C, then Ctrl/Cmd + V',
'Ctrl/Cmd + X',
'Alt + drag',
],
correctIndex: 1,
explanation:
'Ctrl/Cmd + C copies the selected block and Ctrl/Cmd + V pastes it. These are the standard copy-paste shortcuts — use them to quickly duplicate blocks on the canvas.',
},
{
type: 'multiple_choice',
question: 'What does the terminal console at the bottom of the canvas show?',
options: [
'The source code of each block',
'Block-by-block execution output, including results and errors',
'A list of available integrations',
'The workflow deployment settings',
],
correctIndex: 1,
explanation:
"The terminal console shows you what happened at each block after a run: outputs, tool calls, token counts, and errors. It's your primary debugging tool.",
},
],
},
},
],
},
{
id: 'sim-foundations-m2',
title: 'The Agent Block',
description:
'Deeply understand the core block in Sim — how agents work, how to attach tools, and how to enforce structured output.',
lessons: [
{
id: 'sim-foundations-m2-l1',
slug: 'how-agents-work',
title: 'How Agents Work',
lessonType: 'video',
description:
'The Agent block as an LLM with a job: system prompts, model selection, the tool call loop, and what the output contains.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 300,
},
{
id: 'sim-foundations-m2-l2',
slug: 'tools-and-integrations',
title: 'Tools & Integrations',
lessonType: 'video',
description:
'How to attach tools to an Agent, how the model decides when to call them, and how to connect credentials.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 240,
},
{
id: 'sim-foundations-m2-l3',
slug: 'structured-output',
title: 'Structured Output',
lessonType: 'video',
description:
'How to use Response Format to enforce a JSON schema on agent output, and how to reference individual fields downstream.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 240,
},
{
id: 'sim-foundations-m2-l4',
slug: 'configure-agent',
title: 'Configure an Agent',
lessonType: 'exercise',
description:
'The Agent block is already wired up. Open its panel and add a system prompt in the Messages field.',
exerciseConfig: {
instructions:
'The Agent block is already connected to the Starter. Click it to open the panel on the right, then add a system message in the Messages field — try something like "You are a helpful assistant that answers concisely." Once set, click Run.',
availableBlocks: [],
initialBlocks: [
{
id: 'starter-1',
type: 'starter',
position: { x: 80, y: 220 },
locked: true,
},
{
id: 'agent-1',
type: 'agent',
position: { x: 380, y: 220 },
locked: false,
subBlocks: { model: 'claude-sonnet-4-5' },
},
],
initialEdges: [
{
id: 'e-starter-agent',
source: 'starter-1',
target: 'agent-1',
sourceHandle: 'source',
targetHandle: 'target',
},
],
validationRules: [
{
type: 'block_configured',
blockType: 'agent',
subBlockId: 'messages',
valueNotEmpty: true,
label: 'Add a system prompt in the Messages field',
},
],
hints: [
'Click the Agent block to select it — the configuration panel opens on the right.',
'In the Messages section, add a system message. Try: "You are a helpful assistant that answers concisely."',
],
mockOutputs: {
starter: { response: { result: 'Workflow started' }, delay: 200 },
'agent-1': {
response: {
content: "Hello! I'm your configured Sim agent. How can I help you today?",
},
delay: 1800,
},
},
},
},
{
id: 'sim-foundations-m2-l5',
slug: 'agent-mastery-check',
title: 'Agent Mastery Check',
lessonType: 'quiz',
quizConfig: {
passingScore: 80,
questions: [
{
type: 'multiple_choice',
question: 'What is the primary purpose of a system prompt on an Agent block?',
options: [
"It sets the model's temperature and token limit",
"It defines the agent's persona, instructions, and constraints",
'It controls which tools the agent is allowed to call',
'It specifies the JSON schema for the response',
],
correctIndex: 1,
explanation:
"The system prompt gives the model its identity and instructions — its role, tone, what it should and shouldn't do. Temperature, tools, and response format are configured separately.",
},
{
type: 'true_false',
question:
'An Agent can call multiple tools in sequence during a single workflow run before producing its final answer.',
correctAnswer: true,
explanation:
'Agents run a tool call loop: the model calls a tool, receives the result, decides if it needs more information, and can call another tool before producing its final answer.',
},
{
type: 'multiple_choice',
question:
'You define a Response Format with a field called "sentiment" on an Agent block. What happens to that field?',
options: [
"It's only available inside the Agent block and can't be used downstream",
'It becomes a named output on the Agent block, selectable via the reference picker in any downstream block',
'It must be extracted manually using a Function block',
"It's merged into the agent's plain-text content output",
],
correctIndex: 1,
explanation:
"Fields defined in a Response Format become individual outputs on the Agent block — instead of just a single 'content' string, you get 'sentiment', 'score', etc. as separate values. In any downstream block, type < to open the reference picker and select exactly the field you need.",
},
{
type: 'multiple_choice',
question: 'What does setting a tool\'s usage mode to "forced" do?',
options: [
'The tool runs before the agent sees the input',
'The model must call that tool at least once during the run',
'The tool is required to return a valid response or the workflow fails',
'The tool is hidden from the model but runs automatically',
],
correctIndex: 1,
explanation:
'Forced mode guarantees the model will call that specific tool — useful when you always need a web search or database lookup regardless of what the user asked. "Auto" lets the model decide.',
},
{
type: 'multi_select',
question:
'Which of these can you attach directly to an Agent block? (select all that apply)',
options: [
'A system prompt',
'External tools (search, Slack, GitHub, etc.)',
'Custom skills defined in workspace settings',
'A response format / JSON schema',
'A deployment schedule',
],
correctIndices: [0, 1, 2, 3],
explanation:
'System prompts, tools, skills, and response format are all configured directly on the Agent block. Deployment schedules are set on the Starter block, not the Agent.',
},
],
},
},
],
},
{
id: 'sim-foundations-m3',
title: 'Data Flow & Variables',
description:
'Understand how data moves between blocks and how to reference outputs across your workflow.',
lessons: [
{
id: 'sim-foundations-m3-l1',
slug: 'variables-and-references',
title: 'Variables & References',
lessonType: 'video',
description:
'The <block.field> reference syntax, the Variables block, environment variables, and live value preview.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 240,
},
{
id: 'sim-foundations-m3-l2',
slug: 'multi-step-pipeline',
title: 'Build a Multi-Step Pipeline',
lessonType: 'exercise',
description:
'Chain two Agent blocks together so the output of the first flows into the second.',
exerciseConfig: {
instructions:
"Add two Agent blocks to the canvas. Connect the Starter to the first Agent, then the first Agent to the second Agent. This creates a pipeline where data flows through both agents in sequence. In the second agent's Messages field, type < to open the reference picker and select the first agent's output — this is how any block feeds its result into the next one.",
availableBlocks: ['agent'],
initialBlocks: [
{
id: 'starter-1',
type: 'starter',
position: { x: 80, y: 240 },
locked: true,
},
],
validationRules: [
{
type: 'block_exists',
blockType: 'agent',
count: 2,
label: 'Add two Agent blocks to the canvas',
},
{
type: 'edge_exists',
sourceType: 'starter',
targetType: 'agent',
label: 'Connect the Starter to the first Agent',
},
{
type: 'edge_exists',
sourceType: 'agent',
targetType: 'agent',
label: 'Connect the first Agent to the second Agent',
},
],
hints: [
'Drag two Agent blocks onto the canvas and position them left to right.',
'Connect Starter → Agent 1 first, then Agent 1 → Agent 2.',
"In the second agent's Messages field, type < to open the reference picker and select the first agent's output.",
],
mockOutputs: {
starter: { response: { result: 'Workflow started' }, delay: 200 },
agent: {
response: { content: 'Step one complete. Passing result to the next agent.' },
delay: 1000,
},
},
},
},
],
},
{
id: 'sim-foundations-m4',
title: 'Control Flow',
description:
"Build workflows that branch, route, run in parallel, and loop using Sim's control flow blocks.",
lessons: [
{
id: 'sim-foundations-m4-l1',
slug: 'conditions-and-routing',
title: 'Conditions & Routing',
lessonType: 'video',
description:
'The Condition block for deterministic branching, the Router block for LLM-powered routing, and when to use each.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 300,
},
{
id: 'sim-foundations-m4-l2',
slug: 'parallel-and-loops',
title: 'Parallel Execution & Loops',
lessonType: 'video',
description:
'How to run branches simultaneously with fan-out, how the Loop block iterates over a list one item at a time, and how the Parallel block processes all items in a list concurrently.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 180,
},
{
id: 'sim-foundations-m4-l3',
slug: 'branching-workflow',
title: 'Branching Workflow',
lessonType: 'exercise',
description:
'Build a workflow that routes to different Agent blocks depending on whether a condition is true or false.',
exerciseConfig: {
instructions:
"Build a branching workflow. First, add a Condition block and connect the Starter to it. Then add two Agent blocks and connect one to each of the Condition's output handles — the top handle is the true branch, the bottom handle is the false branch. The Condition evaluates a JavaScript expression and routes execution to whichever branch matches.",
availableBlocks: ['condition', 'agent'],
initialBlocks: [
{
id: 'starter-1',
type: 'starter',
position: { x: 80, y: 260 },
locked: true,
},
],
validationRules: [
{
type: 'block_exists',
blockType: 'condition',
label: 'Add a Condition block',
},
{
type: 'block_exists',
blockType: 'agent',
count: 2,
label: 'Add two Agent blocks (one for each branch)',
},
{
type: 'edge_exists',
sourceType: 'starter',
targetType: 'condition',
label: 'Connect the Starter to the Condition',
},
{
type: 'edge_exists',
sourceType: 'condition',
targetType: 'agent',
sourceHandle: 'condition-if',
label: 'Connect the Condition true branch (top handle) to an Agent',
},
{
type: 'edge_exists',
sourceType: 'condition',
targetType: 'agent',
sourceHandle: 'condition-else',
label: 'Connect the Condition false branch (bottom handle) to an Agent',
},
],
hints: [
'Add a Condition block — it shows two output handles on the right: the top one is the true branch, the bottom one is the false branch.',
'Connect Starter → Condition first, then add two Agent blocks and drag one connection from each output handle to an Agent.',
"Click the Condition block to set your expression. Try `true` to always take the true branch while you're testing the wiring.",
],
mockOutputs: {
starter: { response: { result: 'Workflow started' }, delay: 200 },
condition: {
response: { result: true },
delay: 400,
},
agent: {
response: { content: 'Taking the true path — condition was met.' },
delay: 1200,
},
},
},
},
{
id: 'sim-foundations-m4-l4',
slug: 'control-flow-check',
title: 'Control Flow Check',
lessonType: 'quiz',
quizConfig: {
passingScore: 75,
questions: [
{
type: 'multiple_choice',
question: 'What does the Condition block evaluate?',
options: [
'A natural language description of a rule',
'A JavaScript expression that resolves to true or false',
'A SQL query against the workflow state',
'An LLM call that decides which path to take',
],
correctIndex: 1,
explanation:
'The Condition block evaluates a JavaScript expression — you can reference block outputs like <agent.sentiment> === "negative" or <start.input>.length > 100. It routes to the true or false branch based on the result.',
},
{
type: 'multiple_choice',
question: 'When would you choose a Router block over a Condition block?',
options: [
'When you need an exact boolean true/false decision',
'When you have 3 or more named paths and natural language input determines the route',
'When you want to run two branches simultaneously',
'When you need to loop over a list of items',
],
correctIndex: 1,
explanation:
'The Router uses an LLM to intelligently select from multiple named paths — ideal for open-ended inputs like support tickets that could be billing, technical, or general. Condition is better for deterministic boolean logic.',
},
{
type: 'true_false',
question: "Blocks on a branch that wasn't taken still execute with an empty input.",
correctAnswer: false,
explanation:
"Blocks on a branch not taken are completely skipped — they don't run at all. Only the matching branch executes.",
},
{
type: 'multiple_choice',
question: 'How do you run two independent blocks at the same time in Sim?',
options: [
'Use a dedicated Parallel block between them',
'Connect the same source block to both target blocks (fan-out)',
'Set both blocks to "async" mode in their settings',
'You cannot — Sim only supports sequential execution',
],
correctIndex: 1,
explanation:
"Fan-out: connect one block's output to multiple downstream blocks and all of them start at the same time once the source finishes. The dedicated Parallel block is different — it's a subflow container that iterates over a list and runs its inner blocks once per item, concurrently.",
},
{
type: 'multiple_choice',
question: 'How do you iterate over a list of items in Sim?',
options: [
'Use the Loop block — a subflow container that runs its inner blocks once for each item in a list',
'By drawing an edge from a block back to an earlier block on the canvas',
'Use the Condition block with a counter variable that increments each pass',
'Loops are not supported in Sim',
],
correctIndex: 0,
explanation:
'Sim has a dedicated Loop block — a subflow container. You place the blocks you want to repeat inside it, point it at a list, and it runs those inner blocks once per item. Inside the loop, <loop.currentItem> gives you the current item and <loop.index> gives you the position.',
},
],
},
},
],
},
{
id: 'sim-foundations-m5',
title: 'Memory & Knowledge',
description:
'Give agents access to documents and conversation history to build truly contextual workflows.',
lessons: [
{
id: 'sim-foundations-m5-l1',
slug: 'knowledge-bases',
title: 'Knowledge Bases',
lessonType: 'video',
description:
'What knowledge bases are, how documents are chunked and embedded, and how to wire a Knowledge block into an Agent.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 240,
},
{
id: 'sim-foundations-m5-l2',
slug: 'agent-memory',
title: 'Agent Memory',
lessonType: 'video',
description:
'Stateless vs stateful agents, the conversationId, and the three memory modes: full conversation, sliding window, and token window.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 180,
},
],
},
{
id: 'sim-foundations-m6',
title: 'Deploying Your Workflow',
description:
'Turn a workflow into a real, production-ready product — as an API, a chat interface, or a scheduled job.',
lessons: [
{
id: 'sim-foundations-m6-l1',
slug: 'deploy-as-api',
title: 'Deploying as an API',
lessonType: 'video',
description:
'How to expose a workflow as an HTTPS REST endpoint, authenticate with API keys, and version your deployment.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 240,
},
{
id: 'sim-foundations-m6-l2',
slug: 'chat-deployments',
title: 'Chat Deployments',
lessonType: 'video',
description:
'Deploy a workflow as a managed chat UI — streaming responses, file uploads, conversation history, and access control.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 180,
},
{
id: 'sim-foundations-m6-l3',
slug: 'schedules-and-webhooks',
title: 'Schedules & Webhooks',
lessonType: 'video',
description:
'Trigger workflows automatically on a schedule with cron expressions, or on-demand via incoming webhooks.',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoDurationSeconds: 180,
},
{
id: 'sim-foundations-m6-l4',
slug: 'final-project',
title: 'Final Project',
lessonType: 'exercise',
description:
'Build a complete workflow from scratch: an Agent with a configured system prompt, routing logic via a Condition block, and separate handlers for each branch.',
exerciseConfig: {
instructions:
"Build a complete multi-step workflow from scratch. Step 1: drag an Agent block onto the canvas and connect the Starter to it — this is your main processing agent. Step 2: click the Agent and add a system prompt in the Messages field. Step 3: add a Condition block and connect your Agent to it. Step 4: add two more Agent blocks and connect one to the Condition's true output and one to its false output. This pattern — intake → process → branch → handle — is the foundation of most real Sim deployments.",
availableBlocks: ['agent', 'condition'],
initialBlocks: [
{
id: 'starter-1',
type: 'starter',
position: { x: 60, y: 280 },
locked: true,
},
],
validationRules: [
{
type: 'edge_exists',
sourceType: 'starter',
targetType: 'agent',
label: 'Connect the Starter to an Agent',
},
{
type: 'block_configured',
blockType: 'agent',
subBlockId: 'messages',
valueNotEmpty: true,
label: 'Configure a system prompt on your main Agent',
},
{
type: 'block_exists',
blockType: 'condition',
label: 'Add a Condition block',
},
{
type: 'edge_exists',
sourceType: 'agent',
targetType: 'condition',
label: 'Connect the Agent to the Condition',
},
{
type: 'block_exists',
blockType: 'agent',
count: 3,
label: 'Add two more Agent blocks for the true and false branches',
},
{
type: 'edge_exists',
sourceType: 'condition',
targetType: 'agent',
sourceHandle: 'condition-if',
label: 'Connect the Condition true branch to an Agent',
},
{
type: 'edge_exists',
sourceType: 'condition',
targetType: 'agent',
sourceHandle: 'condition-else',
label: 'Connect the Condition false branch to an Agent',
},
],
hints: [
'Start by placing an Agent block and connecting it to the Starter. Click it to add a system prompt.',
'Add a Condition block and connect your first Agent to it.',
'Add two more Agent blocks — one for the true branch and one for the false branch.',
'You can copy a block with Ctrl/Cmd+C and paste with Ctrl/Cmd+V to save time.',
],
mockOutputs: {
starter: { response: { result: 'Workflow started' }, delay: 200 },
agent: {
response: {
content: 'Analysis complete. Routing to the appropriate handler.',
},
delay: 1500,
},
condition: {
response: { result: true },
delay: 400,
},
},
},
},
],
},
],
}

View File

@@ -1,16 +0,0 @@
import type { Course } from '@/lib/academy/types'
import { simFoundations } from './courses/sim-foundations'
/** All published courses in display order. */
export const COURSES: Course[] = [simFoundations]
const bySlug = new Map(COURSES.map((c) => [c.slug, c]))
const byId = new Map(COURSES.map((c) => [c.id, c]))
export function getCourse(slug: string): Course | undefined {
return bySlug.get(slug)
}
export function getCourseById(id: string): Course | undefined {
return byId.get(id)
}

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