Skip to content
Open
8 changes: 8 additions & 0 deletions apps/sim/instrumentation-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,12 @@ async function initializeOpenTelemetry() {

export async function register() {
await initializeOpenTelemetry()

// Initialize internal scheduler for self-hosted environments
try {
const { initializeInternalScheduler } = await import('./lib/scheduler/internal-scheduler')
initializeInternalScheduler()
} catch (error) {
logger.error('Failed to initialize internal scheduler', error)
}
}
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export const env = createEnv({
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
ENABLE_INTERNAL_SCHEDULER: z.string().optional(), // Enable built-in scheduler for self-hosted environments
INTERNAL_SCHEDULER_INTERVAL_MS: z.string().optional(), // Internal scheduler poll interval (default: 60000ms)
JOB_RETENTION_DAYS: z.string().optional().default('1'), // Days to retain job logs/data

// Cloud Storage - AWS S3
Expand Down
97 changes: 97 additions & 0 deletions apps/sim/lib/scheduler/internal-scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/config/env', () => ({
env: {
ENABLE_INTERNAL_SCHEDULER: 'true',
CRON_SECRET: 'test-secret',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
INTERNAL_SCHEDULER_INTERVAL_MS: '1000',
},
}))

vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))

const mockFetch = vi.fn()
global.fetch = mockFetch

describe('Internal Scheduler', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ executedCount: 0 }),
})
})

afterEach(() => {
vi.clearAllMocks()
})

it('should poll schedules endpoint with correct authentication', async () => {
const { startInternalScheduler, stopInternalScheduler } = await import(
'./internal-scheduler'
)

startInternalScheduler()

// Wait for the initial poll to complete
await new Promise((resolve) => setTimeout(resolve, 100))

expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/schedules/execute',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer test-secret',
'User-Agent': 'sim-studio-internal-scheduler/1.0',
}),
})
)

stopInternalScheduler()
})

it('should handle fetch errors gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))

const { startInternalScheduler, stopInternalScheduler } = await import(
'./internal-scheduler'
)

// Should not throw
startInternalScheduler()
await new Promise((resolve) => setTimeout(resolve, 100))
stopInternalScheduler()
})

it('should handle non-ok responses', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => 'Unauthorized',
})

const { startInternalScheduler, stopInternalScheduler } = await import(
'./internal-scheduler'
)

// Should not throw
startInternalScheduler()
await new Promise((resolve) => setTimeout(resolve, 100))
stopInternalScheduler()
})
})

describe('shouldEnableInternalScheduler', () => {
it('should return true when ENABLE_INTERNAL_SCHEDULER is true', async () => {
const { shouldEnableInternalScheduler } = await import('./internal-scheduler')
expect(shouldEnableInternalScheduler()).toBe(true)
})
})
134 changes: 134 additions & 0 deletions apps/sim/lib/scheduler/internal-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Internal Scheduler for Self-Hosted Environments
*
* This module provides a built-in scheduler that periodically polls the
* /api/schedules/execute endpoint to trigger scheduled workflows.
* This is necessary for self-hosted environments that don't have access
* to external cron services like Vercel Cron Jobs.
*
* Enable by setting ENABLE_INTERNAL_SCHEDULER=true in your environment.
*/

import { env } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('InternalScheduler')

const DEFAULT_POLL_INTERVAL_MS = 60000 // 1 minute

let schedulerInterval: ReturnType<typeof setInterval> | null = null
let isRunning = false

/**
* Execute the schedule poll
*/
async function pollSchedules(): Promise<void> {
if (isRunning) {
logger.debug('Previous poll still running, skipping this cycle')
return
}

isRunning = true

try {
const appUrl = env.NEXT_PUBLIC_APP_URL || env.BETTER_AUTH_URL || 'http://localhost:3000'
const cronSecret = env.CRON_SECRET

if (!cronSecret) {
logger.warn('CRON_SECRET not configured, internal scheduler cannot authenticate')
return
}

const response = await fetch(`${appUrl}/api/schedules/execute`, {
method: 'GET',
headers: {
Authorization: `Bearer ${cronSecret}`,
'User-Agent': 'sim-studio-internal-scheduler/1.0',
},
})

if (!response.ok) {
const errorText = await response.text()
logger.error('Schedule poll failed', {
status: response.status,
error: errorText,
})
return
}

const result = await response.json()
if (result.executedCount > 0) {
logger.info(`Triggered ${result.executedCount} scheduled workflow(s)`)
}
} catch (error) {
logger.error('Error during schedule poll', error)
} finally {
isRunning = false
}
}

/**
* Start the internal scheduler
*/
export function startInternalScheduler(): void {
if (schedulerInterval) {
logger.warn('Internal scheduler already running')
return
}

const pollInterval = Number(env.INTERNAL_SCHEDULER_INTERVAL_MS) || DEFAULT_POLL_INTERVAL_MS

logger.info(`Starting internal scheduler with poll interval: ${pollInterval}ms`)

// Run immediately on start
void pollSchedules()

// Then run at regular intervals
schedulerInterval = setInterval(() => {
void pollSchedules()
}, pollInterval)
}

/**
* Stop the internal scheduler
*/
export function stopInternalScheduler(): void {
if (schedulerInterval) {
clearInterval(schedulerInterval)
schedulerInterval = null
logger.info('Internal scheduler stopped')
}
}

/**
* Check if the internal scheduler should be enabled
*/
export function shouldEnableInternalScheduler(): boolean {
return env.ENABLE_INTERNAL_SCHEDULER === 'true'
}

/**
* Initialize the internal scheduler if enabled
*/
export function initializeInternalScheduler(): void {
if (!shouldEnableInternalScheduler()) {
logger.debug('Internal scheduler disabled (set ENABLE_INTERNAL_SCHEDULER=true to enable)')
return
}

if (!env.CRON_SECRET) {
logger.warn('Cannot start internal scheduler: CRON_SECRET is not configured')
return
}

startInternalScheduler()

// Graceful shutdown handlers
process.on('SIGTERM', () => {
stopInternalScheduler()
})

process.on('SIGINT', () => {
stopInternalScheduler()
})
}
4 changes: 4 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ services:
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://localhost:3002}
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002}
# Internal scheduler for self-hosted environments (enables scheduled workflows)
- ENABLE_INTERNAL_SCHEDULER=${ENABLE_INTERNAL_SCHEDULER:-true}
- CRON_SECRET=${CRON_SECRET:-default-cron-secret-change-me}
- INTERNAL_SCHEDULER_INTERVAL_MS=${INTERNAL_SCHEDULER_INTERVAL_MS:-60000}
depends_on:
db:
condition: service_healthy
Expand Down
4 changes: 4 additions & 0 deletions helm/sim/templates/networkpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ spec:
ports:
- protocol: TCP
port: 443
# Allow custom egress rules
{{- with .Values.networkPolicy.egress }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}

{{- if .Values.postgresql.enabled }}
Expand Down