d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db, workflowSchedule } from '@sim/db'
|
|
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { and, eq, isNull } from 'drizzle-orm'
|
|
import type { NextRequest } from 'next/server'
|
|
import type { ScheduleContext } from '@/lib/api/contracts/schedules'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
import {
|
|
computeNextRunAt,
|
|
parseCronToHumanReadable,
|
|
validateCronExpression,
|
|
} from '@/lib/workflows/schedules/utils'
|
|
|
|
const logger = createLogger('ScheduleOrchestration')
|
|
|
|
type ScheduleErrorCode = 'not_found' | 'validation' | 'internal'
|
|
|
|
interface ActorMetadata {
|
|
actorName?: string | null
|
|
actorEmail?: string | null
|
|
request?: NextRequest
|
|
}
|
|
|
|
export interface PerformCreateJobParams extends ActorMetadata {
|
|
workspaceId: string
|
|
userId: string
|
|
title?: string | null
|
|
prompt: string
|
|
cronExpression?: string | null
|
|
time?: string | null
|
|
timezone: string
|
|
lifecycle?: 'persistent' | 'until_complete'
|
|
successCondition?: string | null
|
|
maxRuns?: number | null
|
|
startDate?: string | null
|
|
/** Recurrence end on a date (ISO 8601); the schedule completes once its next run would fall after this. */
|
|
endsAt?: string | null
|
|
/** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */
|
|
contexts?: ScheduleContext[] | null
|
|
sourceChatId?: string | null
|
|
sourceTaskName?: string | null
|
|
}
|
|
|
|
export interface PerformScheduleResult {
|
|
success: boolean
|
|
error?: string
|
|
errorCode?: ScheduleErrorCode
|
|
schedule?: typeof workflowSchedule.$inferSelect
|
|
humanReadable?: string
|
|
updatedFields?: string[]
|
|
alreadyCompleted?: boolean
|
|
}
|
|
|
|
export interface PerformUpdateJobParams extends ActorMetadata {
|
|
jobId: string
|
|
workspaceId: string
|
|
userId: string
|
|
title?: string
|
|
prompt?: string
|
|
cronExpression?: string | null
|
|
time?: string | null
|
|
timezone?: string
|
|
status?: string
|
|
lifecycle?: string
|
|
successCondition?: string | null
|
|
maxRuns?: number | null
|
|
endsAt?: string | null
|
|
contexts?: ScheduleContext[] | null
|
|
}
|
|
|
|
export interface PerformExcludeOccurrenceParams extends ActorMetadata {
|
|
jobId: string
|
|
workspaceId: string
|
|
userId: string
|
|
/** The exact occurrence instant to skip (ISO 8601), as produced by the recurrence. */
|
|
occurrence: string
|
|
}
|
|
|
|
export interface PerformDeleteJobParams extends ActorMetadata {
|
|
jobId: string
|
|
workspaceId: string
|
|
userId: string
|
|
}
|
|
|
|
export interface PerformCompleteJobParams extends ActorMetadata {
|
|
jobId: string
|
|
workspaceId: string
|
|
userId: string
|
|
}
|
|
|
|
const activeJobCondition = (jobId: string, workspaceId: string) =>
|
|
and(
|
|
eq(workflowSchedule.id, jobId),
|
|
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
|
eq(workflowSchedule.sourceType, 'job'),
|
|
isNull(workflowSchedule.archivedAt)
|
|
)
|
|
|
|
function parseOneTimeRun(time: string, timezone: string): Date | null {
|
|
let timeStr = time
|
|
const hasOffset = /[Zz]|[+-]\d{2}(:\d{2})?$/.test(timeStr)
|
|
if (!hasOffset && timezone !== 'UTC') {
|
|
try {
|
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: timezone,
|
|
timeZoneName: 'shortOffset',
|
|
})
|
|
const parts = formatter.formatToParts(new Date())
|
|
const offsetPart = parts.find((part) => part.type === 'timeZoneName')
|
|
const match = offsetPart?.value.match(/GMT([+-]\d{1,2}(?::\d{2})?)/)
|
|
if (match) {
|
|
const [rawHours, rawMinutes = '00'] = match[1].split(':')
|
|
const sign = rawHours.startsWith('-') ? '-' : '+'
|
|
const hour = Number(rawHours.replace(/^[+-]/, ''))
|
|
if (Number.isFinite(hour)) {
|
|
const offset = `${sign}${String(hour).padStart(2, '0')}:${rawMinutes.padStart(2, '0')}`
|
|
timeStr = `${timeStr}${offset}`
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const parsed = new Date(timeStr)
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
}
|
|
|
|
export async function performCreateJob(
|
|
params: PerformCreateJobParams
|
|
): Promise<PerformScheduleResult> {
|
|
if (!params.prompt.trim()) {
|
|
return { success: false, error: 'prompt is required', errorCode: 'validation' }
|
|
}
|
|
|
|
const cronExpression = params.cronExpression || null
|
|
if (!cronExpression && !params.time) {
|
|
return {
|
|
success: false,
|
|
error: 'At least one of cronExpression or time must be provided',
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
|
|
let endsAt: Date | null = null
|
|
if (params.endsAt) {
|
|
const parsedEndsAt = new Date(params.endsAt)
|
|
if (Number.isNaN(parsedEndsAt.getTime())) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid endsAt value: ${params.endsAt}`,
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
endsAt = parsedEndsAt
|
|
}
|
|
|
|
let nextRunAt: Date | null = null
|
|
if (cronExpression) {
|
|
const validation = validateCronExpression(cronExpression, params.timezone)
|
|
if (!validation.isValid) {
|
|
return {
|
|
success: false,
|
|
error: validation.error || 'Invalid cron expression',
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
nextRunAt = computeNextRunAt({ cronExpression, timezone: params.timezone, endsAt })
|
|
}
|
|
|
|
if (params.time) {
|
|
const parsed = parseOneTimeRun(params.time, params.timezone)
|
|
if (!parsed) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid time value: ${params.time}`,
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
if (!cronExpression || parsed > new Date()) nextRunAt = parsed
|
|
}
|
|
|
|
if (params.startDate) {
|
|
const start = new Date(params.startDate)
|
|
if (start > new Date()) nextRunAt = start
|
|
}
|
|
|
|
if (!nextRunAt) {
|
|
return { success: false, error: 'Could not determine next run time', errorCode: 'validation' }
|
|
}
|
|
|
|
try {
|
|
const id = generateId()
|
|
const now = new Date()
|
|
await db.insert(workflowSchedule).values({
|
|
id,
|
|
workflowId: null,
|
|
cronExpression,
|
|
triggerType: 'schedule',
|
|
sourceType: 'job',
|
|
status: 'active',
|
|
timezone: params.timezone,
|
|
nextRunAt,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
failedCount: 0,
|
|
jobTitle: params.title?.trim() || null,
|
|
prompt: params.prompt.trim(),
|
|
lifecycle: params.lifecycle || 'persistent',
|
|
successCondition: params.successCondition || null,
|
|
maxRuns: params.maxRuns ?? null,
|
|
runCount: 0,
|
|
contexts: params.contexts ?? null,
|
|
excludedDates: null,
|
|
endsAt,
|
|
sourceChatId: params.sourceChatId || null,
|
|
sourceTaskName: params.sourceTaskName || null,
|
|
sourceUserId: params.userId,
|
|
sourceWorkspaceId: params.workspaceId,
|
|
})
|
|
|
|
const [schedule] = await db
|
|
.select()
|
|
.from(workflowSchedule)
|
|
.where(eq(workflowSchedule.id, id))
|
|
.limit(1)
|
|
|
|
const humanReadable = cronExpression
|
|
? parseCronToHumanReadable(cronExpression, params.timezone)
|
|
: `Once at ${nextRunAt.toISOString()}`
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.SCHEDULE_CREATED,
|
|
resourceType: AuditResourceType.SCHEDULE,
|
|
resourceId: id,
|
|
resourceName: params.title?.trim() || undefined,
|
|
description: `Created job schedule "${params.title?.trim() || id}"`,
|
|
metadata: {
|
|
cronExpression,
|
|
timezone: params.timezone,
|
|
lifecycle: params.lifecycle || 'persistent',
|
|
maxRuns: params.maxRuns ?? null,
|
|
},
|
|
request: params.request,
|
|
})
|
|
|
|
captureServerEvent(
|
|
params.userId,
|
|
'scheduled_task_created',
|
|
{ workspace_id: params.workspaceId },
|
|
{ groups: { workspace: params.workspaceId } }
|
|
)
|
|
|
|
if (schedule?.workflowId) {
|
|
captureServerEvent(
|
|
params.userId,
|
|
'workflow_schedule_created',
|
|
{ workflow_id: schedule.workflowId, workspace_id: params.workspaceId },
|
|
{ groups: { workspace: params.workspaceId } }
|
|
)
|
|
}
|
|
|
|
return { success: true, schedule, humanReadable }
|
|
} catch (error) {
|
|
logger.error('Failed to create job', { error: toError(error).message })
|
|
return { success: false, error: 'Failed to create job', errorCode: 'internal' }
|
|
}
|
|
}
|
|
|
|
export async function performUpdateJob(
|
|
params: PerformUpdateJobParams
|
|
): Promise<PerformScheduleResult> {
|
|
try {
|
|
const [job] = await db
|
|
.select()
|
|
.from(workflowSchedule)
|
|
.where(activeJobCondition(params.jobId, params.workspaceId))
|
|
.limit(1)
|
|
|
|
if (!job)
|
|
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
|
|
|
const updates: Partial<typeof workflowSchedule.$inferInsert> = { updatedAt: new Date() }
|
|
if (params.title !== undefined) updates.jobTitle = params.title.trim()
|
|
if (params.prompt !== undefined) updates.prompt = params.prompt.trim()
|
|
if (params.timezone !== undefined) updates.timezone = params.timezone
|
|
if (params.status !== undefined) {
|
|
if (!['active', 'paused', 'disabled'].includes(params.status)) {
|
|
return {
|
|
success: false,
|
|
error: 'status must be "active" or "paused"',
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
updates.status = params.status === 'paused' ? 'disabled' : params.status
|
|
}
|
|
if (params.lifecycle !== undefined) {
|
|
if (params.lifecycle !== 'persistent' && params.lifecycle !== 'until_complete') {
|
|
return {
|
|
success: false,
|
|
error: 'lifecycle must be "persistent" or "until_complete"',
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
updates.lifecycle = params.lifecycle
|
|
if (params.lifecycle === 'persistent') updates.maxRuns = null
|
|
}
|
|
if (params.successCondition !== undefined) updates.successCondition = params.successCondition
|
|
if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns
|
|
if (params.contexts !== undefined) updates.contexts = params.contexts
|
|
const effectiveStatus = updates.status ?? job.status
|
|
|
|
let endsAt: Date | null = job.endsAt
|
|
if (params.endsAt !== undefined) {
|
|
if (params.endsAt === null) {
|
|
endsAt = null
|
|
} else {
|
|
const parsedEndsAt = new Date(params.endsAt)
|
|
if (Number.isNaN(parsedEndsAt.getTime())) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid endsAt value: ${params.endsAt}`,
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
endsAt = parsedEndsAt
|
|
}
|
|
updates.endsAt = endsAt
|
|
}
|
|
|
|
if (params.cronExpression !== undefined && params.cronExpression !== null) {
|
|
const timezone = params.timezone || job.timezone || 'UTC'
|
|
const validation = validateCronExpression(params.cronExpression, timezone)
|
|
if (!validation.isValid) {
|
|
return {
|
|
success: false,
|
|
error: validation.error || 'Invalid cron expression',
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
updates.cronExpression = params.cronExpression
|
|
if (effectiveStatus === 'active') {
|
|
updates.nextRunAt = computeNextRunAt({
|
|
cronExpression: params.cronExpression,
|
|
timezone,
|
|
excludedDates: job.excludedDates,
|
|
endsAt,
|
|
})
|
|
}
|
|
} else if (params.cronExpression === null) {
|
|
updates.cronExpression = null
|
|
}
|
|
if (params.time !== undefined && params.time !== null) {
|
|
const timezone = params.timezone || job.timezone || 'UTC'
|
|
const parsed = parseOneTimeRun(params.time, timezone)
|
|
if (!parsed) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid time value: ${params.time}`,
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
const cronExpression =
|
|
params.cronExpression !== undefined ? params.cronExpression : job.cronExpression
|
|
if (effectiveStatus === 'active' && (!cronExpression || parsed > new Date())) {
|
|
updates.nextRunAt = parsed
|
|
}
|
|
}
|
|
|
|
const updatedFields = Object.keys(updates).filter((key) => key !== 'updatedAt')
|
|
|
|
await db
|
|
.update(workflowSchedule)
|
|
.set(updates)
|
|
.where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.SCHEDULE_UPDATED,
|
|
resourceType: AuditResourceType.SCHEDULE,
|
|
resourceId: params.jobId,
|
|
resourceName: job.jobTitle ?? undefined,
|
|
description: `Updated job schedule "${job.jobTitle ?? params.jobId}"`,
|
|
metadata: { operation: 'update', updatedFields },
|
|
request: params.request,
|
|
})
|
|
|
|
return { success: true, updatedFields }
|
|
} catch (error) {
|
|
logger.error('Failed to update job', { error: toError(error).message })
|
|
return { success: false, error: 'Failed to update job', errorCode: 'internal' }
|
|
}
|
|
}
|
|
|
|
export async function performExcludeOccurrence(
|
|
params: PerformExcludeOccurrenceParams
|
|
): Promise<PerformScheduleResult> {
|
|
const occurrence = new Date(params.occurrence)
|
|
if (Number.isNaN(occurrence.getTime())) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid occurrence value: ${params.occurrence}`,
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
|
|
try {
|
|
const [job] = await db
|
|
.select()
|
|
.from(workflowSchedule)
|
|
.where(activeJobCondition(params.jobId, params.workspaceId))
|
|
.limit(1)
|
|
|
|
if (!job)
|
|
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
|
if (!job.cronExpression) {
|
|
return {
|
|
success: false,
|
|
error: 'Only recurring tasks have individual occurrences to delete',
|
|
errorCode: 'validation',
|
|
}
|
|
}
|
|
|
|
const occurrenceIso = occurrence.toISOString()
|
|
const excludedDates = Array.from(new Set([...(job.excludedDates ?? []), occurrenceIso]))
|
|
|
|
const updates: Partial<typeof workflowSchedule.$inferInsert> = {
|
|
excludedDates,
|
|
updatedAt: new Date(),
|
|
}
|
|
|
|
if (job.nextRunAt && job.nextRunAt.getTime() === occurrence.getTime()) {
|
|
const nextRunAt = computeNextRunAt({
|
|
cronExpression: job.cronExpression,
|
|
timezone: job.timezone || 'UTC',
|
|
from: occurrence,
|
|
excludedDates,
|
|
endsAt: job.endsAt,
|
|
})
|
|
updates.nextRunAt = nextRunAt
|
|
if (!nextRunAt) updates.status = 'completed'
|
|
}
|
|
|
|
await db
|
|
.update(workflowSchedule)
|
|
.set(updates)
|
|
.where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.SCHEDULE_UPDATED,
|
|
resourceType: AuditResourceType.SCHEDULE,
|
|
resourceId: params.jobId,
|
|
resourceName: job.jobTitle ?? undefined,
|
|
description: `Deleted one occurrence of job "${job.jobTitle ?? params.jobId}"`,
|
|
metadata: { operation: 'exclude_occurrence', occurrence: occurrenceIso },
|
|
request: params.request,
|
|
})
|
|
|
|
return { success: true }
|
|
} catch (error) {
|
|
logger.error('Failed to exclude occurrence', { error: toError(error).message })
|
|
return { success: false, error: 'Failed to delete occurrence', errorCode: 'internal' }
|
|
}
|
|
}
|
|
|
|
export async function performDeleteJob(
|
|
params: PerformDeleteJobParams
|
|
): Promise<PerformScheduleResult> {
|
|
const [job] = await db
|
|
.select()
|
|
.from(workflowSchedule)
|
|
.where(activeJobCondition(params.jobId, params.workspaceId))
|
|
.limit(1)
|
|
|
|
if (!job)
|
|
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
|
|
|
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, params.jobId))
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.SCHEDULE_DELETED,
|
|
resourceType: AuditResourceType.SCHEDULE,
|
|
resourceId: params.jobId,
|
|
resourceName: job.jobTitle ?? undefined,
|
|
description: `Deleted job "${job.jobTitle ?? params.jobId}"`,
|
|
metadata: {
|
|
sourceType: job.sourceType,
|
|
cronExpression: job.cronExpression,
|
|
timezone: job.timezone,
|
|
},
|
|
request: params.request,
|
|
})
|
|
|
|
captureServerEvent(
|
|
params.userId,
|
|
'scheduled_task_deleted',
|
|
{ workspace_id: params.workspaceId },
|
|
{ groups: { workspace: params.workspaceId } }
|
|
)
|
|
|
|
if (job.workflowId) {
|
|
captureServerEvent(
|
|
params.userId,
|
|
'workflow_schedule_deleted',
|
|
{ workflow_id: job.workflowId, workspace_id: params.workspaceId },
|
|
{ groups: { workspace: params.workspaceId } }
|
|
)
|
|
}
|
|
|
|
return { success: true, schedule: job }
|
|
}
|
|
|
|
export async function performCompleteJob(
|
|
params: PerformCompleteJobParams
|
|
): Promise<PerformScheduleResult> {
|
|
const [job] = await db
|
|
.select()
|
|
.from(workflowSchedule)
|
|
.where(activeJobCondition(params.jobId, params.workspaceId))
|
|
.limit(1)
|
|
|
|
if (!job)
|
|
return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
|
|
if (job.status === 'completed') return { success: true, schedule: job, alreadyCompleted: true }
|
|
|
|
const [updatedJob] = await db
|
|
.update(workflowSchedule)
|
|
.set({ status: 'completed', nextRunAt: null, updatedAt: new Date() })
|
|
.where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
|
|
.returning()
|
|
|
|
recordAudit({
|
|
workspaceId: params.workspaceId,
|
|
actorId: params.userId,
|
|
actorName: params.actorName ?? undefined,
|
|
actorEmail: params.actorEmail ?? undefined,
|
|
action: AuditAction.SCHEDULE_UPDATED,
|
|
resourceType: AuditResourceType.SCHEDULE,
|
|
resourceId: params.jobId,
|
|
description: 'Completed job',
|
|
metadata: { operation: 'complete' },
|
|
request: params.request,
|
|
})
|
|
|
|
return { success: true, schedule: updatedJob, alreadyCompleted: false }
|
|
}
|