Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here}
- COPILOT_API_KEY=${COPILOT_API_KEY}
- NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-}
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
| Variable | Description |
|----------|-------------|
| `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` |
| `COPILOT_API_KEY` | API key for copilot features |
| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run |
| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key |
| `ADMIN_API_KEY` | Admin API key for GitOps operations |
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients.

# Chat (Optional)
# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run
# NEXT_PUBLIC_CHAT_DISABLED=true # Hides the Chat module: the workspace lands on your first workflow, and the chats list, scheduled tasks, and editor Chat panel are absent. Chat is shown when unset; `bun run setup` sets this for you if you skip the chat key

# Security (Required)
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/mothership/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { NextRequest } from 'next/server'
import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createWorkspaceSSE } from '@/lib/events/sse-endpoint'

Expand All @@ -37,6 +38,10 @@ const mothershipEventsHandler = createWorkspaceSSE({
})

export const GET = withRouteHandler((request: NextRequest) => {
// Closes streams held by tabs that were open when Chat was turned off; the
// client hook already declines to open new ones.
if (!isChatEnabled) return new Response(null, { status: 404 })

const validation = mothershipEventsQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries())
)
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/app/api/schedules/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
requestUtilsMockFns,
resetDbChainMock,
resetEnvFlagsMock,
resetEnvMock,
setEnv,
setEnvFlags,
} from '@sim/testing'
import { type NextRequest, NextResponse } from 'next/server'
Expand Down Expand Up @@ -275,7 +277,10 @@ function createMockRequest(): NextRequest {
} as NextRequest
}

afterAll(resetEnvFlagsMock)
afterAll(() => {
resetEnvFlagsMock()
resetEnvMock()
})

describe('Scheduled Workflow Execution API Route', () => {
beforeEach(() => {
Expand All @@ -290,6 +295,9 @@ describe('Scheduled Workflow Execution API Route', () => {
dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never)
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id')
setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true })
// Prompt-job claims are skipped without the mothership credential; pin it so
// these cases do not depend on whether the runner happens to have a .env.
setEnv({ COPILOT_API_KEY: 'test-api-key' })
mockShouldExecuteInline.mockReturnValue(false)
mockEnqueue.mockReset()
mockEnqueue.mockResolvedValue('job-id-1')
Expand Down
14 changes: 13 additions & 1 deletion apps/sim/app/api/schedules/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@/lib/billing/core/billing-attribution'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types'
import { env } from '@/lib/core/config/env'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { runDetached } from '@/lib/core/utils/background'
Expand Down Expand Up @@ -1245,7 +1246,18 @@ export async function runScheduleTick(requestId: string): Promise<ScheduleTickRe
let iterations = 0
let remainingWorkflowBudget = SCHEDULE_WORKFLOW_ENQUEUE_LIMIT
let schedulesExhausted = false
let jobsExhausted = false
/**
* Prompt jobs run through the mothership, so without a key every claim ends in
* a 401. Skipping the claim entirely leaves the rows `active` and resumable;
* claiming them would burn each one through `MAX_CONSECUTIVE_FAILURES` and
* permanently disable a schedule the user can no longer see, let alone stop.
* Keyed on the credential rather than `CHAT_ENABLED` so jobs keep running for
* a deployment that only hid the UI.
*/
let jobsExhausted = !env.COPILOT_API_KEY
if (jobsExhausted) {
logger.info(`[${requestId}] COPILOT_API_KEY not set, skipping prompt job claims`)
}

while (Date.now() - tickStart < MAX_TICK_DURATION_MS) {
if (schedulesExhausted && jobsExhausted) break
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/cli/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@ export const dynamic = 'force-dynamic'
/**
* Browser half of the CLI key handoff.
*
* Signed-out visitors bounce through login carrying a *re-serialized*
* Signed-out visitors bounce through signup carrying a *re-serialized*
* `callbackUrl` — only the params the handoff understands survive, so the round
* trip cannot be used to smuggle anything else back into this page. The request
* is validated before that bounce: a bogus callback is rejected here rather
* than after making the user sign in for nothing.
*
* Signup rather than login because this page is reached from a terminal: the
* setup wizard sends people here while standing up a self-host, and someone
* configuring Sim for the first time has no account yet. Both auth pages
* cross-link carrying the same `callbackUrl`, so a returning user is one click
* from login with their destination intact.
*/
export default async function CliAuthPage({
searchParams,
Expand All @@ -43,7 +49,7 @@ export default async function CliAuthPage({
challenge: resolution.request.challenge,
pairing: resolution.request.pairing,
})
redirect(`/login?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`)
redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`)
}

return (
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { BrandedLayout } from '@/components/branded-layout'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
import '@/app/_styles/globals.css'
import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/env-flags'
import {
isChatEnabled,
isHosted,
isReactGrabEnabled,
isReactScanEnabled,
} from '@/lib/core/config/env-flags'
import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate'
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
Expand Down Expand Up @@ -150,6 +155,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}

var activeTab = panelState && panelState.activeTab;
// A session that used the Chat tab before it was turned off still
// has 'copilot' persisted; without this the CSS hides every tab
// body and the panel paints empty.
if (activeTab === 'copilot' && !${isChatEnabled}) {
activeTab = 'toolbar';
}
if (activeTab) {
document.documentElement.setAttribute('data-panel-active-tab', activeTab);
}
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/chat/[chatId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { notFound } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'

export default function ChatLayout({ children }: { children: React.ReactNode }) {
if (!isChatEnabled) notFound()

return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}
21 changes: 20 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { redirect } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { inter } from '@/app/_styles/fonts/inter/inter'

export default function HomeLayout({ children }: { children: React.ReactNode }) {
/**
* Redirects rather than 404s when Chat is disabled: this path is baked into
* already-delivered invitation emails and into the invitation-accept API
* contract, so it has to keep resolving. `/workspace/{id}` re-resolves the
* landing route server-side, so the visitor lands on a workflow instead.
*/
export default async function HomeLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ workspaceId: string }>
}) {
if (!isChatEnabled) {
const { workspaceId } = await params
redirect(`/workspace/${workspaceId}`)
}

return (
<div className={`flex h-full flex-1 flex-col overflow-hidden ${inter.variable}`}>
{children}
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch'
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
Expand All @@ -15,6 +17,12 @@ export const metadata: Metadata = {
export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) {
const { workspaceId } = await params

// The layout redirects too, but pages and layouts resolve concurrently — without
// this the prefetch below still fires on its way out.
if (!isChatEnabled) {
redirect(`/workspace/${workspaceId}`)
}

const queryClient = getQueryClient()
const listsPrefetch = prefetchHomeLists(queryClient, workspaceId)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import {
blockTypeToIconMap,
type Integration,
Expand Down Expand Up @@ -157,11 +158,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
Add to Sim
</Chip>
)
) : (
) : isChatEnabled ? (
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
Add to Sim
</Chip>
)}
) : null}
</div>
</div>
{oauthService && (
Expand Down Expand Up @@ -243,7 +244,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
/>
)}

{matchingTemplates.length > 0 && (
{/* Every template hands its prompt to Chat, so the section has no
destination without it. */}
{isChatEnabled && matchingTemplates.length > 0 && (
<TemplatesSection
integration={integration}
templates={matchingTemplates}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'

Expand All @@ -29,17 +30,19 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
return (
<div className='relative'>
<IntegrationsShowcase />
<Chip
active
rightIcon={ArrowRight}
onClick={() => {
storeCuratedPrompt(prompt)
router.push(`/workspace/${workspaceId}/home`)
}}
className='absolute right-0 bottom-0 mx-0'
>
Explore in chat
</Chip>
{isChatEnabled && (
<Chip
active
rightIcon={ArrowRight}
onClick={() => {
storeCuratedPrompt(prompt)
router.push(`/workspace/${workspaceId}/home`)
}}
className='absolute right-0 bottom-0 mx-0'
>
Explore in chat
</Chip>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { createPortal } from 'react-dom'
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
Expand Down Expand Up @@ -397,7 +398,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
* mothership-triggered logs are excluded — `isLikelyExecution` already encodes
* "has an executionId and isn't a mothership run".
*/
const canTroubleshoot = log.status === 'failed' && isLikelyExecution
const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution

/**
* Hands the failed run to Chat. When a chat is already mounted (e.g. the run
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/workspace/[workspaceId]/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ErrorShell } from '@/app/workspace/[workspaceId]/components'
export default function WorkspaceNotFound() {
const router = useRouter()
const { workspaceId } = useParams<{ workspaceId?: string }>()
const homeHref = workspaceId ? `/workspace/${workspaceId}/home` : '/'
const homeHref = workspaceId ? `/workspace/${workspaceId}` : '/'

return (
<ErrorShell
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { redirect } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'

/**
* Resolves the workspace landing route: the chat composer, or `/w`, which
* selects the first workflow from the list the layout already prefetched.
*
* Deliberately does no work of its own. Resolving the workflow here would mean
* a session lookup, an access check, and a query before anything renders — and
* a slow database would leave the user on a blank page instead of a redirect,
* since there is nothing to show until all three finish.
*/
export default async function WorkspacePage({
params,
}: {
params: Promise<{ workspaceId: string }>
}) {
const { workspaceId } = await params
redirect(`/workspace/${workspaceId}/home`)
redirect(`/workspace/${workspaceId}/${isChatEnabled ? 'home' : 'w'}`)
}
21 changes: 13 additions & 8 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { QueryClient } from '@tanstack/react-query'
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { listFoldersForWorkspace } from '@/lib/folders/queries'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
Expand Down Expand Up @@ -73,14 +74,18 @@ export async function prefetchWorkspaceSidebar(
},
staleTime: WORKFLOW_LIST_STALE_TIME,
}),
queryClient.prefetchQuery({
queryKey: mothershipChatKeys.list(workspaceId, 'active'),
queryFn: async () => {
const data = await listMothershipChats(userId, workspaceId)
return data.map(mapChat)
},
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
}),
...(isChatEnabled
? [
queryClient.prefetchQuery({
queryKey: mothershipChatKeys.list(workspaceId, 'active'),
queryFn: async () => {
const data = await listMothershipChats(userId, workspaceId)
return data.map(mapChat)
},
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
}),
]
: []),
queryClient.prefetchQuery({
queryKey: folderKeys.list(workspaceId, 'active', 'workflow'),
queryFn: async () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import ScheduledTasksLoading from '@/app/workspace/[workspaceId]/scheduled-tasks/loading'
import { ScheduledTasks } from './scheduled-tasks'

Expand All @@ -14,6 +16,10 @@ export const metadata: Metadata = {
* so a suspend never shows a blank frame.
*/
export default function ScheduledTasksPage() {
// The calendar only surfaces mothership prompt jobs, so with Chat off there is
// nothing this page could ever show.
if (!isChatEnabled) notFound()

return (
<Suspense fallback={<ScheduledTasksLoading />}>
<ScheduledTasks />
Expand Down
Loading
Loading