Skip to content
Open
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
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
91 changes: 91 additions & 0 deletions apps/sim/lib/scheduler/internal-scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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
Loading