From 0e52815bd53003a70d1a44fefbb78d547561f26e Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Sat, 14 Mar 2026 21:43:04 +0700 Subject: [PATCH 01/15] feat: implement scheduling functionality for AgentflowV2 - Added ScheduleQueue class for managing scheduled jobs using BullMQ. - Introduced scheduleService for creating, updating, and disabling schedules. - Integrated schedule validation and cron expression handling. - Enhanced chatflow service to manage schedules for agentflows. - Added UI components for time and day selection (TimePicker, WeekDaysPicker, MonthDaysPicker). - Updated NodeInputHandler to support new input types for scheduling. - Improved validation for schedule configurations in agentflows. --- .../components/nodes/agentflow/Start/Start.ts | 144 +++++- packages/server/src/Interface.Schedule.ts | 23 + packages/server/src/commands/worker.ts | 11 + .../src/database/entities/ScheduleRecord.ts | 58 +++ .../database/entities/ScheduleTriggerLog.ts | 51 +++ .../server/src/database/entities/index.ts | 6 +- .../1772000000000-AddScheduleEntities.ts | 53 +++ .../src/database/migrations/mariadb/index.ts | 5 +- .../1772000000000-AddScheduleEntities.ts | 53 +++ .../src/database/migrations/mysql/index.ts | 5 +- .../1772000000000-AddScheduleEntities.ts | 53 +++ .../src/database/migrations/postgres/index.ts | 5 +- .../1772000000000-AddScheduleEntities.ts | 51 +++ .../src/database/migrations/sqlite/index.ts | 5 +- packages/server/src/index.ts | 5 + packages/server/src/queue/QueueManager.ts | 19 +- packages/server/src/queue/ScheduleBeat.ts | 327 ++++++++++++++ packages/server/src/queue/ScheduleQueue.ts | 205 +++++++++ .../server/src/services/chatflows/index.ts | 91 +++- .../server/src/services/schedule/index.ts | 412 ++++++++++++++++++ .../server/src/services/validation/index.ts | 9 + .../ui-component/picker/MonthDaysPicker.jsx | 87 ++++ .../ui/src/ui-component/picker/TimePicker.jsx | 49 +++ .../ui-component/picker/WeekDaysPicker.jsx | 93 ++++ packages/ui/src/utils/genericHelper.js | 5 +- .../src/views/agentflowsv2/AgentFlowNode.jsx | 39 +- .../ui/src/views/canvas/NodeInputHandler.jsx | 26 ++ 27 files changed, 1879 insertions(+), 11 deletions(-) create mode 100644 packages/server/src/Interface.Schedule.ts create mode 100644 packages/server/src/database/entities/ScheduleRecord.ts create mode 100644 packages/server/src/database/entities/ScheduleTriggerLog.ts create mode 100644 packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts create mode 100644 packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts create mode 100644 packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts create mode 100644 packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts create mode 100644 packages/server/src/queue/ScheduleBeat.ts create mode 100644 packages/server/src/queue/ScheduleQueue.ts create mode 100644 packages/server/src/services/schedule/index.ts create mode 100644 packages/ui/src/ui-component/picker/MonthDaysPicker.jsx create mode 100644 packages/ui/src/ui-component/picker/TimePicker.jsx create mode 100644 packages/ui/src/ui-component/picker/WeekDaysPicker.jsx diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 833e3b7c2eb..2cb8c348281 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -18,7 +18,7 @@ class Start_Agentflow implements INode { constructor() { this.label = 'Start' this.name = 'startAgentflow' - this.version = 1.1 + this.version = 1.2 this.type = 'Start' this.category = 'Agent Flows' this.description = 'Starting point of the agentflow' @@ -40,6 +40,11 @@ class Start_Agentflow implements INode { label: 'Form Input', name: 'formInput', description: 'Start the workflow with form inputs' + }, + { + label: 'Schedule Input', + name: 'scheduleInput', + description: 'Start the workflow on a recurring schedule (cron)' } ], default: 'chatInput' @@ -125,6 +130,135 @@ class Start_Agentflow implements INode { } ] }, + { + label: 'Schedule Type', + name: 'scheduleType', + type: 'options', + options: [ + { + label: 'Visual Picker', + name: 'visualPicker', + description: 'Use a visual picker to select schedule options' + }, + { + label: 'Cron Expression', + name: 'cronExpression', + description: 'Use a cron expression to define the schedule' + } + ], + show: { + startInputType: 'scheduleInput' + } + }, + { + label: 'Cron Expression', + name: 'scheduleCronExpression', + type: 'string', + placeholder: '0 9 * * 1-5', + description: + 'Standard 5-field cron expression (minute hour day month weekday). Example: "0 9 * * 1-5" runs at 09:00 every weekday.', + show: { + startInputType: 'scheduleInput', + scheduleType: 'cronExpression' + } + }, + { + label: 'Frequency', + name: 'scheduleFrequency', + type: 'options', + options: [ + { + label: 'Hourly', + name: 'hourly', + description: 'Run every hour at the specified time' + }, + { + label: 'Daily', + name: 'daily', + description: 'Run every day at the specified time' + }, + { + label: 'Weekly', + name: 'weekly', + description: 'Run every week on the specified day and time' + }, + { + label: 'Monthly', + name: 'monthly', + description: 'Run every month on the specified date and time' + } + ], + show: { + startInputType: 'scheduleInput', + scheduleType: 'visualPicker' + } + }, + { + label: 'On Minute', + name: 'scheduleOnMinute', + type: 'number', + placeholder: '30', + description: + 'Minute of the hour when the schedule should run (0-59). For example, "30" means the schedule will run at the 30th minute of the hour.', + show: { + startInputType: 'scheduleInput', + scheduleType: 'visualPicker', + scheduleFrequency: 'hourly' + } + }, + { + label: 'On Time', + name: 'scheduleOnTime', + type: 'timePicker', + show: { + startInputType: 'scheduleInput', + scheduleType: 'visualPicker', + scheduleFrequency: ['daily', 'weekly', 'monthly'] + } + }, + { + label: 'On Day of Week', + name: 'scheduleOnDayOfWeek', + type: 'weekDaysPicker', + show: { + startInputType: 'scheduleInput', + scheduleType: 'visualPicker', + scheduleFrequency: 'weekly' + } + }, + { + label: 'On Day of Month', + name: 'scheduleOnDayOfMonth', + type: 'monthDaysPicker', + show: { + startInputType: 'scheduleInput', + scheduleType: 'visualPicker', + scheduleFrequency: 'monthly' + } + }, + { + label: 'Timezone', + name: 'scheduleTimezone', + type: 'string', + placeholder: 'UTC', + description: 'IANA timezone name, e.g. America/New_York. Defaults to UTC.', + optional: true, + show: { + startInputType: 'scheduleInput' + } + }, + { + label: 'Default Input', + name: 'scheduleDefaultInput', + type: 'string', + placeholder: 'Run the daily report', + description: 'Default question/input passed to the flow when it is triggered by the scheduler.', + acceptVariable: true, + rows: 4, + show: { + startInputType: 'scheduleInput' + } + }, { label: 'Ephemeral Memory', name: 'startEphemeralMemory', @@ -213,6 +347,14 @@ class Start_Agentflow implements INode { outputData.form = form } + if (startInputType === 'scheduleInput') { + const defaultInput = nodeData.inputs?.scheduleDefaultInput as string + const effectiveInput = (typeof input === 'string' && input) || defaultInput || '' + inputData.question = effectiveInput + outputData.question = effectiveInput + outputData.scheduledAt = options.agentflowRuntime?.scheduledAt ?? new Date().toISOString() + } + if (startEphemeralMemory) { outputData.ephemeralMemory = true } diff --git a/packages/server/src/Interface.Schedule.ts b/packages/server/src/Interface.Schedule.ts new file mode 100644 index 00000000000..02827d6bc1f --- /dev/null +++ b/packages/server/src/Interface.Schedule.ts @@ -0,0 +1,23 @@ +import { DataSource } from 'typeorm' +import { IComponentNodes } from './Interface' +import { Telemetry } from './utils/telemetry' +import { CachePool } from './CachePool' +import { UsageCacheManager } from './UsageCacheManager' + +export interface IScheduleQueueAppServer { + appDataSource: DataSource + componentNodes: IComponentNodes + telemetry: Telemetry + cachePool: CachePool + usageCacheManager: UsageCacheManager +} + +export interface IScheduleAgentflowJobData extends IScheduleQueueAppServer { + scheduleRecordId: string + targetId: string + cronExpression: string + timezone: string + defaultInput?: string + workspaceId: string + scheduledAt: string // ISO string +} diff --git a/packages/server/src/commands/worker.ts b/packages/server/src/commands/worker.ts index e993c73608e..fe08fedf832 100644 --- a/packages/server/src/commands/worker.ts +++ b/packages/server/src/commands/worker.ts @@ -16,6 +16,7 @@ interface CustomListener extends QueueEventsListener { export default class Worker extends BaseCommand { predictionWorkerId: string upsertionWorkerId: string + scheduleWorkerId: string async run(): Promise { logger.info('Starting Flowise Worker...') @@ -51,6 +52,12 @@ export default class Worker extends BaseCommand { this.upsertionWorkerId = upsertionWorker.id logger.info(`Upsertion Worker ${this.upsertionWorkerId} created`) + /** Schedule */ + const scheduleQueue = queueManager.getQueue('schedule') + const scheduleWorker = scheduleQueue.createWorker() + this.scheduleWorkerId = scheduleWorker.id + logger.info(`Schedule Worker ${this.scheduleWorkerId} created`) + // Keep the process running process.stdin.resume() } @@ -98,6 +105,10 @@ export default class Worker extends BaseCommand { const upsertWorker = queueManager.getQueue('upsert').getWorker() logger.info(`Shutting down Flowise Upsertion Worker ${this.upsertionWorkerId}...`) await upsertWorker.close() + + const scheduleWorker = queueManager.getQueue('schedule').getWorker() + logger.info(`Shutting down Flowise Schedule Worker...`) + await scheduleWorker.close() } catch (error) { logger.error('There was an error shutting down Flowise Worker...', error) await this.failExit() diff --git a/packages/server/src/database/entities/ScheduleRecord.ts b/packages/server/src/database/entities/ScheduleRecord.ts new file mode 100644 index 00000000000..18491bb40ee --- /dev/null +++ b/packages/server/src/database/entities/ScheduleRecord.ts @@ -0,0 +1,58 @@ +/* eslint-disable */ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm' + +export enum ScheduleTriggerType { + AGENTFLOW = 'AGENTFLOW' +} + +@Entity() +export class ScheduleRecord { + @PrimaryGeneratedColumn('uuid') + id: string + + /** Discriminator: which entity type is being scheduled */ + @Column({ type: 'varchar', length: 32 }) + triggerType: ScheduleTriggerType + + /** FK to the target entity (ChatFlow.id for AGENTFLOW) */ + @Index() + @Column({ type: 'varchar' }) + targetId: string + + /** Node ID within the flow (for traceability) */ + @Column({ nullable: true, type: 'text' }) + nodeId?: string + + /** Standard 5 or 6 field cron expression */ + @Column({ type: 'text' }) + cronExpression: string + + /** IANA timezone string, e.g. "UTC" or "America/New_York" */ + @Column({ type: 'varchar', length: 64, default: 'UTC' }) + timezone: string + + /** Whether the schedule is active */ + @Column({ type: 'boolean', default: true }) + enabled: boolean + + /** Optional static text sent as question when the flow fires */ + @Column({ nullable: true, type: 'text' }) + defaultInput?: string + + @Column({ nullable: true, type: 'timestamp' }) + lastRunAt?: Date + + @Column({ nullable: true, type: 'timestamp' }) + nextRunAt?: Date + + @Column({ type: 'varchar' }) + workspaceId: string + + @Column({ type: 'timestamp' }) + @CreateDateColumn() + createdDate: Date + + @Column({ type: 'timestamp' }) + @UpdateDateColumn() + updatedDate: Date +} diff --git a/packages/server/src/database/entities/ScheduleTriggerLog.ts b/packages/server/src/database/entities/ScheduleTriggerLog.ts new file mode 100644 index 00000000000..4d3dbe4b1c7 --- /dev/null +++ b/packages/server/src/database/entities/ScheduleTriggerLog.ts @@ -0,0 +1,51 @@ +/* eslint-disable */ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm' +import { ScheduleTriggerType } from './ScheduleRecord' + +export enum ScheduleTriggerStatus { + QUEUED = 'QUEUED', + RUNNING = 'RUNNING', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', + SKIPPED = 'SKIPPED' +} + +@Entity() +export class ScheduleTriggerLog { + @PrimaryGeneratedColumn('uuid') + id: string + + @Index() + @Column({ type: 'varchar' }) + scheduleRecordId: string + + @Column({ type: 'varchar', length: 32 }) + triggerType: ScheduleTriggerType + + @Index() + @Column({ type: 'varchar' }) + targetId: string + + /** Resulting execution/chatMessage ID (for agentflow triggers) */ + @Column({ nullable: true, type: 'varchar' }) + executionId?: string + + @Column({ type: 'varchar', length: 32 }) + status: ScheduleTriggerStatus + + @Column({ nullable: true, type: 'text' }) + error?: string + + @Column({ nullable: true, type: 'integer' }) + elapsedTimeMs?: number + + @Column({ type: 'timestamp' }) + scheduledAt: Date + + @Column({ type: 'varchar' }) + workspaceId: string + + @Column({ type: 'timestamp' }) + @CreateDateColumn() + createdDate: Date +} diff --git a/packages/server/src/database/entities/index.ts b/packages/server/src/database/entities/index.ts index ad19b4e2e80..8920e04843d 100644 --- a/packages/server/src/database/entities/index.ts +++ b/packages/server/src/database/entities/index.ts @@ -26,6 +26,8 @@ import { Workspace } from '../../enterprise/database/entities/workspace.entity' import { WorkspaceUser } from '../../enterprise/database/entities/workspace-user.entity' import { LoginMethod } from '../../enterprise/database/entities/login-method.entity' import { LoginSession } from '../../enterprise/database/entities/login-session.entity' +import { ScheduleRecord } from './ScheduleRecord' +import { ScheduleTriggerLog } from './ScheduleTriggerLog' export const entities = { ChatFlow, @@ -57,5 +59,7 @@ export const entities = { Workspace, WorkspaceUser, LoginMethod, - LoginSession + LoginSession, + ScheduleRecord, + ScheduleTriggerLog } diff --git a/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts new file mode 100644 index 00000000000..e4419732ce8 --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddScheduleEntities1772000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS \`schedule_record\` ( + \`id\` varchar(36) NOT NULL, + \`triggerType\` varchar(32) NOT NULL, + \`targetId\` varchar(255) NOT NULL, + \`nodeId\` text, + \`cronExpression\` text NOT NULL, + \`timezone\` varchar(64) NOT NULL DEFAULT 'UTC', + \`enabled\` tinyint(1) NOT NULL DEFAULT 1, + \`defaultInput\` text, + \`lastRunAt\` datetime(6), + \`nextRunAt\` datetime(6), + \`workspaceId\` varchar(255) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + `) + + await queryRunner.query(`CREATE INDEX \`IDX_schedule_record_targetId\` ON \`schedule_record\` (\`targetId\`);`) + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS \`schedule_trigger_log\` ( + \`id\` varchar(36) NOT NULL, + \`scheduleRecordId\` varchar(255) NOT NULL, + \`triggerType\` varchar(32) NOT NULL, + \`targetId\` varchar(255) NOT NULL, + \`executionId\` varchar(255), + \`status\` varchar(32) NOT NULL, + \`error\` text, + \`elapsedTimeMs\` int, + \`scheduledAt\` datetime(6) NOT NULL, + \`workspaceId\` varchar(255) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + `) + + await queryRunner.query( + `CREATE INDEX \`IDX_schedule_trigger_log_scheduleRecordId\` ON \`schedule_trigger_log\` (\`scheduleRecordId\`);` + ) + await queryRunner.query(`CREATE INDEX \`IDX_schedule_trigger_log_targetId\` ON \`schedule_trigger_log\` (\`targetId\`);`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`schedule_trigger_log\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`schedule_record\``) + } +} diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index f9d3d5fdcd8..4af00e4da88 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -43,6 +43,8 @@ import { AddChatFlowNameIndex1759424809984 } from './1759424809984-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables' import { AddWorkspace1725437498242 } from '../../../enterprise/database/migrations/mariadb/1725437498242-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/mariadb/1726654922034-AddWorkspaceShared' @@ -111,5 +113,6 @@ export const mariadbMigrations = [ AddChatFlowNameIndex1759424809984, FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddScheduleEntities1772000000000 ] diff --git a/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts new file mode 100644 index 00000000000..bb106c43202 --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddScheduleEntities1772000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS \`schedule_record\` ( + \`id\` varchar(36) NOT NULL, + \`triggerType\` varchar(32) NOT NULL, + \`targetId\` varchar(255) NOT NULL, + \`nodeId\` text, + \`cronExpression\` text NOT NULL, + \`timezone\` varchar(64) NOT NULL DEFAULT 'UTC', + \`enabled\` tinyint(1) NOT NULL DEFAULT 1, + \`defaultInput\` text, + \`lastRunAt\` datetime(6), + \`nextRunAt\` datetime(6), + \`workspaceId\` varchar(255) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + `) + + await queryRunner.query(`CREATE INDEX \`IDX_schedule_record_targetId\` ON \`schedule_record\` (\`targetId\`);`) + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS \`schedule_trigger_log\` ( + \`id\` varchar(36) NOT NULL, + \`scheduleRecordId\` varchar(255) NOT NULL, + \`triggerType\` varchar(32) NOT NULL, + \`targetId\` varchar(255) NOT NULL, + \`executionId\` varchar(255), + \`status\` varchar(32) NOT NULL, + \`error\` text, + \`elapsedTimeMs\` int, + \`scheduledAt\` datetime(6) NOT NULL, + \`workspaceId\` varchar(255) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + `) + + await queryRunner.query( + `CREATE INDEX \`IDX_schedule_trigger_log_scheduleRecordId\` ON \`schedule_trigger_log\` (\`scheduleRecordId\`);` + ) + await queryRunner.query(`CREATE INDEX \`IDX_schedule_trigger_log_targetId\` ON \`schedule_trigger_log\` (\`targetId\`);`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS \`schedule_trigger_log\``) + await queryRunner.query(`DROP TABLE IF EXISTS \`schedule_record\``) + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index a22168aefcf..af98faecdd6 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -44,6 +44,8 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/mysql/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/mysql/1726654922034-AddWorkspaceShared' @@ -113,5 +115,6 @@ export const mysqlMigrations = [ AddChatFlowNameIndex1759424828558, FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddScheduleEntities1772000000000 ] diff --git a/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts new file mode 100644 index 00000000000..5a2605236cd --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddScheduleEntities1772000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS schedule_record ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + "triggerType" varchar(32) NOT NULL, + "targetId" varchar NOT NULL, + "nodeId" text, + "cronExpression" text NOT NULL, + "timezone" varchar(64) NOT NULL DEFAULT 'UTC', + "enabled" boolean NOT NULL DEFAULT true, + "defaultInput" text, + "lastRunAt" timestamp, + "nextRunAt" timestamp, + "workspaceId" varchar NOT NULL, + "createdDate" timestamp NOT NULL DEFAULT now(), + "updatedDate" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_schedule_record" PRIMARY KEY (id) + ); + `) + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_schedule_record_targetId" ON schedule_record ("targetId");`) + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS schedule_trigger_log ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + "scheduleRecordId" varchar NOT NULL, + "triggerType" varchar(32) NOT NULL, + "targetId" varchar NOT NULL, + "executionId" varchar, + "status" varchar(32) NOT NULL, + "error" text, + "elapsedTimeMs" integer, + "scheduledAt" timestamp NOT NULL, + "workspaceId" varchar NOT NULL, + "createdDate" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_schedule_trigger_log" PRIMARY KEY (id) + ); + `) + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_schedule_trigger_log_scheduleRecordId" ON schedule_trigger_log ("scheduleRecordId");` + ) + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_schedule_trigger_log_targetId" ON schedule_trigger_log ("targetId");`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS schedule_trigger_log`) + await queryRunner.query(`DROP TABLE IF EXISTS schedule_record`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 9303033e02b..d6f2c1e8529 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -42,6 +42,8 @@ import { AddTextToSpeechToChatFlow1759419194331 } from './1759419194331-AddTextT import { AddChatFlowNameIndex1759424903973 } from './1759424903973-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/postgres/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/postgres/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/postgres/1726654922034-AddWorkspaceShared' @@ -109,5 +111,6 @@ export const postgresMigrations = [ AddTextToSpeechToChatFlow1759419194331, AddChatFlowNameIndex1759424903973, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddScheduleEntities1772000000000 ] diff --git a/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts new file mode 100644 index 00000000000..ba892275cf2 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddScheduleEntities1772000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "schedule_record" ( + "id" varchar PRIMARY KEY NOT NULL, + "triggerType" varchar(32) NOT NULL, + "targetId" varchar NOT NULL, + "nodeId" text, + "cronExpression" text NOT NULL, + "timezone" varchar(64) NOT NULL DEFAULT 'UTC', + "enabled" boolean NOT NULL DEFAULT 1, + "defaultInput" text, + "lastRunAt" datetime, + "nextRunAt" datetime, + "workspaceId" varchar NOT NULL, + "createdDate" datetime NOT NULL DEFAULT (datetime('now')), + "updatedDate" datetime NOT NULL DEFAULT (datetime('now')) + ); + `) + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_schedule_record_targetId" ON "schedule_record" ("targetId");`) + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "schedule_trigger_log" ( + "id" varchar PRIMARY KEY NOT NULL, + "scheduleRecordId" varchar NOT NULL, + "triggerType" varchar(32) NOT NULL, + "targetId" varchar NOT NULL, + "executionId" varchar, + "status" varchar(32) NOT NULL, + "error" text, + "elapsedTimeMs" integer, + "scheduledAt" datetime NOT NULL, + "workspaceId" varchar NOT NULL, + "createdDate" datetime NOT NULL DEFAULT (datetime('now')) + ); + `) + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_schedule_trigger_log_scheduleRecordId" ON "schedule_trigger_log" ("scheduleRecordId");` + ) + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_schedule_trigger_log_targetId" ON "schedule_trigger_log" ("targetId");`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "schedule_trigger_log"`) + await queryRunner.query(`DROP TABLE IF EXISTS "schedule_record"`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 90b42a2475f..904a163a781 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -40,6 +40,8 @@ import { AddTextToSpeechToChatFlow1759419136055 } from './1759419136055-AddTextT import { AddChatFlowNameIndex1759424923093 } from './1759424923093-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/sqlite/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/sqlite/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/sqlite/1726654922034-AddWorkspaceShared' @@ -105,5 +107,6 @@ export const sqliteMigrations = [ AddTextToSpeechToChatFlow1759419136055, AddChatFlowNameIndex1759424923093, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddScheduleEntities1772000000000 ] diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index bbb31910e9e..b2a893fe9b4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,6 +23,7 @@ import { Prometheus } from './metrics/Prometheus' import errorHandlerMiddleware from './middlewares/errors' import { NodesPool } from './NodesPool' import { QueueManager } from './queue/QueueManager' +import { ScheduleBeat } from './queue/ScheduleBeat' import { RedisEventSubscriber } from './queue/RedisEventSubscriber' import flowiseApiV1Router from './routes' import { UsageCacheManager } from './UsageCacheManager' @@ -151,6 +152,10 @@ export class App { logger.info('πŸ”— [server]: Redis event subscriber connected successfully') } + // Init ScheduleBeat (works in both queue and non-queue mode) + await ScheduleBeat.getInstance().init() + logger.info('⏰ [server]: ScheduleBeat initialized successfully') + logger.info('πŸŽ‰ [server]: All initialization steps completed successfully!') } catch (error) { logger.error('❌ [server]: Error during Data Source initialization:', error) diff --git a/packages/server/src/queue/QueueManager.ts b/packages/server/src/queue/QueueManager.ts index eef90b33b94..7740e1bbb6b 100644 --- a/packages/server/src/queue/QueueManager.ts +++ b/packages/server/src/queue/QueueManager.ts @@ -1,6 +1,7 @@ import { BaseQueue } from './BaseQueue' import { PredictionQueue } from './PredictionQueue' import { UpsertQueue } from './UpsertQueue' +import { ScheduleQueue } from './ScheduleQueue' import { IComponentNodes } from '../Interface' import { Telemetry } from '../utils/telemetry' import { CachePool } from '../CachePool' @@ -15,7 +16,7 @@ import { ExpressAdapter } from '@bull-board/express' const QUEUE_NAME = process.env.QUEUE_NAME || 'flowise-queue' -type QUEUE_TYPE = 'prediction' | 'upsert' +type QUEUE_TYPE = 'prediction' | 'upsert' | 'schedule' export class QueueManager { private static instance: QueueManager @@ -154,9 +155,23 @@ export class QueueManager { }) this.registerQueue('upsert', upsertionQueue) + const scheduleQueueName = `${QUEUE_NAME}-schedule` + const scheduleQueue = new ScheduleQueue(scheduleQueueName, this.connection, { + componentNodes, + telemetry, + cachePool, + appDataSource, + usageCacheManager + }) + this.registerQueue('schedule', scheduleQueue) + if (serverAdapter) { createBullBoard({ - queues: [new BullMQAdapter(predictionQueue.getQueue()), new BullMQAdapter(upsertionQueue.getQueue())], + queues: [ + new BullMQAdapter(predictionQueue.getQueue()), + new BullMQAdapter(upsertionQueue.getQueue()), + new BullMQAdapter(scheduleQueue.getQueue()) + ], serverAdapter: serverAdapter }) this.bullBoardRouter = serverAdapter.getRouter() diff --git a/packages/server/src/queue/ScheduleBeat.ts b/packages/server/src/queue/ScheduleBeat.ts new file mode 100644 index 00000000000..1e7847edde8 --- /dev/null +++ b/packages/server/src/queue/ScheduleBeat.ts @@ -0,0 +1,327 @@ +/** + * ScheduleBeat + * + * Responsible for keeping BullMQ repeatable jobs (or in-process timers) + * in sync with the ScheduleRecord table. + * + * Queue mode : delegates scheduling to BullMQ repeat jobs via ScheduleQueue. + * Non-queue mode: uses setInterval-based in-process timers (1-minute resolution). + * + * Either way, ScheduleBeat.init() must be called once after the DB is ready. + */ + +import { getRunningExpressApp } from '../utils/getRunningExpressApp' +import { ScheduleRecord } from '../database/entities/ScheduleRecord' +import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' +import { ScheduleQueue } from './ScheduleQueue' +import { QueueManager } from './QueueManager' +import scheduleService from '../services/schedule' +import { executeAgentFlow } from '../utils/buildAgentflow' +import { IncomingAgentflowInput, MODE } from '../Interface' +import { ChatFlow } from '../database/entities/ChatFlow' +import { v4 as uuidv4 } from 'uuid' +import logger from '../utils/logger' + +// --------------------------------------------------------------------------- +// Minimal cron-expression parser for non-queue mode +// Returns true if the given Date matches the cron pattern (minute resolution) +// Supports: * and */step, individual values, ranges (a-b), and comma lists +// --------------------------------------------------------------------------- +function matchesCronField(field: string, value: number, min: number, max: number): boolean { + if (field === '*') return true + + for (const part of field.split(',')) { + if (part.includes('/')) { + const [rangeStr, stepStr] = part.split('/') + const step = parseInt(stepStr, 10) + if (isNaN(step)) continue + if (rangeStr === '*') { + if ((value - min) % step === 0) return true + } else if (rangeStr.includes('-')) { + const [start, end] = rangeStr.split('-').map(Number) + if (value >= start && value <= end && (value - start) % step === 0) return true + } else { + if (value === parseInt(rangeStr, 10)) return true + } + } else if (part.includes('-')) { + const [start, end] = part.split('-').map(Number) + if (value >= start && value <= end) return true + } else { + if (value === parseInt(part, 10)) return true + } + } + return false +} + +function matchesCron(expression: string, date: Date, timezone: string = 'UTC'): boolean { + // Extract date/time fields in the target timezone using Intl.DateTimeFormat.formatToParts() + // to avoid locale/DST-dependent round-tripping through Date parsing. + let minute: number, hour: number, dom: number, month: number, dow: number + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + weekday: 'short', + hour12: false + }).formatToParts(date) + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) + const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun' + minute = get('minute') + hour = get('hour') % 24 // hour12:false returns 24 for midnight in some runtimes + dom = get('day') + month = get('month') + dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayStr) + if (dow === -1) dow = date.getUTCDay() // safety fallback + } catch { + // Fallback to UTC fields if the timezone identifier is invalid + minute = date.getUTCMinutes() + hour = date.getUTCHours() + dom = date.getUTCDate() + month = date.getUTCMonth() + 1 + dow = date.getUTCDay() + } + + const fields = expression.trim().split(/\s+/) + // Support 5-field (standard) and 6-field (with seconds) cron + const offset = fields.length === 6 ? 1 : 0 + const minuteField = fields[0 + offset] + const hourField = fields[1 + offset] + const domField = fields[2 + offset] + const monthField = fields[3 + offset] + const dowField = fields[4 + offset] + + // Cron allows both 0 and 7 for Sunday; Date.getDay() only returns 0. + // When dow is 0 (Sunday), also evaluate the field against 7 so expressions + // like "7", "1-7", or "5-7" match correctly. + const dowMatches = matchesCronField(dowField, dow, 0, 7) || (dow === 0 && matchesCronField(dowField, 7, 0, 7)) + return ( + matchesCronField(minuteField, minute, 0, 59) && + matchesCronField(hourField, hour, 0, 23) && + matchesCronField(domField, dom, 1, 31) && + matchesCronField(monthField, month, 1, 12) && + dowMatches + ) +} + +// --------------------------------------------------------------------------- + +export class ScheduleBeat { + private static instance: ScheduleBeat + private inProcessTimer: NodeJS.Timeout | null = null + private isQueueMode: boolean + + private constructor() { + this.isQueueMode = process.env.MODE === MODE.QUEUE + } + + public static getInstance(): ScheduleBeat { + if (!ScheduleBeat.instance) { + ScheduleBeat.instance = new ScheduleBeat() + } + return ScheduleBeat.instance + } + + /** + * Initialize scheduling. Must be called after the DB is initialized. + * + * NOTE: In non-queue mode, schedules are executed via in-process timers without + * any distributed locking or leader election. If the API is deployed with + * multiple replicas and all of them call ScheduleBeat.init(), each replica + * will run the same schedules, causing duplicate executions. For High Availability (HA) / multi- + * replica deployments, configure MODE.QUEUE and use the queue-based scheduler. + */ + public async init(): Promise { + logger.info(`[ScheduleBeat]: Initializing in ${this.isQueueMode ? 'queue' : 'non-queue'} mode`) + if (this.isQueueMode) { + await this._syncQueueModeSchedules() + } else { + logger.warn( + '[ScheduleBeat]: Running in non-queue mode with in-process timers and no distributed locking. ' + + 'If multiple API replicas are running, schedules will be executed once per replica. ' + + 'For High Availability (HA) deployments, enable queue mode (MODE.QUEUE) to avoid duplicate executions.' + ) + this._startInProcessTimer() + } + } + + /** + * Call this after a schedule is created/updated/deleted to resync the queue. + * In queue mode it re-registers the BullMQ repeatable job. + * In non-queue mode the in-process timer re-reads the DB on every tick, so no action needed. + */ + public async onScheduleChanged(scheduleRecordId: string, action: 'upsert' | 'delete'): Promise { + if (!this.isQueueMode) return // no-op; timer picks up DB changes automatically + try { + // NOTE: Need to load the ScheduleRecord here to get the cron expression and timezone for removing the old job. + const appServer = getRunningExpressApp() + const scheduleRecord = await appServer.AppDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId }) + if (!scheduleRecord) { + logger.warn(`[ScheduleBeat]: Schedule record ${scheduleRecordId} not found for onScheduleChanged`) + return + } + const scheduleQueue = QueueManager.getInstance().getQueue('schedule') as ScheduleQueue | undefined + if (!scheduleQueue) { + logger.warn('[ScheduleBeat]: ScheduleQueue not available β€” cannot sync schedule changes') + return + } + if (action === 'delete' || !scheduleRecord.enabled) { + await scheduleQueue.removeJobScheduler(scheduleRecord.id) + } else { + // Remove old job first (in case cron expression changed) + await scheduleQueue.removeJobScheduler(scheduleRecord.id) + // Add new job + await scheduleQueue.upsertJobScheduler(scheduleRecord) + } + } catch (error) { + logger.error(`[ScheduleBeat]: onScheduleChanged error: ${error}`) + } + } + + /** + * Stop all scheduling activity (called on graceful shutdown). + */ + public async shutdown(): Promise { + if (this.inProcessTimer) { + clearInterval(this.inProcessTimer) + this.inProcessTimer = null + } + } + + // ─── Queue mode ──────────────────────────────────────────────────────────── + + private async _syncQueueModeSchedules(): Promise { + try { + const scheduleQueue = QueueManager.getInstance().getQueue('schedule') as ScheduleQueue | undefined + if (!scheduleQueue) { + logger.warn('[ScheduleBeat]: ScheduleQueue not available β€” skipping sync') + return + } + + // NOTE: This naively re-registers all enabled schedules on every startup. + // BullMQ will deduplicate them by jobId, so this is safe but could be optimized by only upserting changed schedules if needed. + const records = await scheduleService.getAllEnabledSchedules() + for (const record of records) { + await scheduleQueue.upsertJobScheduler(record) + } + logger.info(`[ScheduleBeat]: Synced ${records.length} schedule(s) to BullMQ`) + + // NOTE: For the disabled schedules, we rely on the fact that ScheduleBeat.onScheduleChanged will be called + // on each schedule update to remove the corresponding repeatable job from BullMQ. + // This means that if a schedule is disabled but not deleted, its repeatable job will still be + // registered on startup but should be removed shortly after when onScheduleChanged is triggered. + // No need to explicitly remove disabled schedules here since they will be cleaned up by onScheduleChanged. + } catch (error) { + logger.error(`[ScheduleBeat]: Failed to sync schedules in queue mode: ${error}`) + } + } + + // ─── Non-queue mode ──────────────────────────────────────────────────────── + + private _startInProcessTimer(): void { + // Tick every 60 seconds, aligned to the start of the next minute + const now = Date.now() + const msToNextMinute = 60_000 - (now % 60_000) + + setTimeout(() => { + this._tick() + this.inProcessTimer = setInterval(() => this._tick(), 60_000) + }, msToNextMinute) + + logger.info(`[ScheduleBeat]: In-process cron timer will fire in ${Math.round(msToNextMinute / 1000)}s`) + } + + private async _tick(): Promise { + const now = new Date() + logger.debug(`[ScheduleBeat]: Tick at ${now.toISOString()}`) + + let records: ScheduleRecord[] + try { + records = await scheduleService.getAllEnabledSchedules() + } catch (error) { + logger.error(`[ScheduleBeat]: Could not load schedules: ${error}`) + return + } + + for (const record of records) { + if (matchesCron(record.cronExpression, now, record.timezone ?? 'UTC')) { + this._fireSchedule(record, now).catch((err) => { + logger.error(`[ScheduleBeat]: Error firing schedule ${record.id}: ${err}`) + }) + } + } + } + + private async _fireSchedule(record: ScheduleRecord, scheduledAt: Date): Promise { + const appServer = getRunningExpressApp() + const appDataSource = appServer.AppDataSource + const startTime = Date.now() + const log = await scheduleService.createTriggerLog({ + appDataSource, + scheduleRecordId: record.id, + triggerType: record.triggerType, + targetId: record.targetId, + status: ScheduleTriggerStatus.RUNNING, + scheduledAt, + workspaceId: record.workspaceId + }) + + try { + const chatflow = await appDataSource.getRepository(ChatFlow).findOneBy({ id: record.targetId }) + if (!chatflow) throw new Error(`ChatFlow ${record.targetId} not found`) + if (chatflow.type !== 'AGENTFLOW') throw new Error(`ChatFlow ${record.targetId} is not type AGENTFLOW`) + + const chatId = uuidv4() + const incomingInput: IncomingAgentflowInput = { + question: record.defaultInput || '', + chatId, + streaming: false + } + + const result = await executeAgentFlow({ + componentNodes: appServer.nodesPool.componentNodes, + incomingInput, + chatflow, + chatId, + appDataSource, + telemetry: appServer.telemetry, + cachePool: appServer.cachePool, + usageCacheManager: appServer.usageCacheManager, + sseStreamer: appServer.sseStreamer, + baseURL: '', + isInternal: true, + uploadedFilesContent: '', + fileUploads: [], + isTool: true, // suppresses SSE streaming + workspaceId: chatflow.workspaceId ?? record.workspaceId, + orgId: '', + subscriptionId: '', + productId: '' + }) + + const elapsedTimeMs = Date.now() - startTime + const executionId: string | undefined = + result && typeof result === 'object' && 'executionId' in result ? (result as any).executionId : undefined + + await scheduleService.updateTriggerLog(appDataSource, log.id, { + status: ScheduleTriggerStatus.SUCCEEDED, + elapsedTimeMs, + executionId + }) + await scheduleService.updateLastRunAt(appDataSource, record.id, new Date()) + logger.debug(`[ScheduleBeat]: Fired schedule ${record.id} successfully (${elapsedTimeMs}ms)`) + } catch (error) { + const elapsedTimeMs = Date.now() - startTime + const errMsg = error instanceof Error ? error.message : String(error) + await scheduleService.updateTriggerLog(appDataSource, log.id, { + status: ScheduleTriggerStatus.FAILED, + elapsedTimeMs, + error: errMsg + }) + logger.error(`[ScheduleBeat]: Schedule ${record.id} failed: ${errMsg}`) + } + } +} diff --git a/packages/server/src/queue/ScheduleQueue.ts b/packages/server/src/queue/ScheduleQueue.ts new file mode 100644 index 00000000000..8f8b645de37 --- /dev/null +++ b/packages/server/src/queue/ScheduleQueue.ts @@ -0,0 +1,205 @@ +import { RedisOptions, RepeatOptions } from 'bullmq' +import { BaseQueue } from './BaseQueue' +import { executeAgentFlow } from '../utils/buildAgentflow' +import { ScheduleRecord, ScheduleTriggerType } from '../database/entities/ScheduleRecord' +import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' +import scheduleService from '../services/schedule' +import { IComponentNodes, IncomingAgentflowInput } from '../Interface' +import { ChatFlow } from '../database/entities/ChatFlow' +import { v4 as uuidv4 } from 'uuid' +import logger from '../utils/logger' +import { IScheduleAgentflowJobData } from '../Interface.Schedule' +import { DataSource } from 'typeorm' +import { Telemetry } from '../utils/telemetry' +import { CachePool } from '../CachePool' +import { UsageCacheManager } from '../UsageCacheManager' +import { RedisEventPublisher } from './RedisEventPublisher' + +interface ScheduleQueueOptions { + appDataSource: DataSource + telemetry: Telemetry + cachePool: CachePool + componentNodes: IComponentNodes + usageCacheManager: UsageCacheManager +} + +interface ScheduleAgentflowJobData { + scheduleRecordId: string + targetId: string + cronExpression: string + timezone: string + defaultInput?: string + workspaceId: string + scheduledAt: string // ISO string +} + +export class ScheduleQueue extends BaseQueue { + private componentNodes: IComponentNodes + private telemetry: Telemetry + private cachePool: CachePool + private appDataSource: DataSource + private usageCacheManager: UsageCacheManager + private redisPublisher: RedisEventPublisher + private queueName: string + + constructor(name: string, connection: RedisOptions, options: ScheduleQueueOptions) { + super(name, connection) + this.queueName = name + this.componentNodes = options.componentNodes || {} + this.telemetry = options.telemetry + this.cachePool = options.cachePool + this.appDataSource = options.appDataSource + this.usageCacheManager = options.usageCacheManager + this.redisPublisher = new RedisEventPublisher() // sseStreamer for agentflow execution results + this.redisPublisher.connect() + } + + public getQueueName() { + return this.queueName + } + + public getQueue() { + return this.queue + } + + async processJob(data: IScheduleAgentflowJobData): Promise { + if (this.appDataSource) data.appDataSource = this.appDataSource + if (this.telemetry) data.telemetry = this.telemetry + if (this.cachePool) data.cachePool = this.cachePool + if (this.usageCacheManager) data.usageCacheManager = this.usageCacheManager + if (this.componentNodes) data.componentNodes = this.componentNodes + + const { scheduleRecordId, targetId, defaultInput, workspaceId } = data + // Compute the effective scheduled time at execution to avoid reusing + // a stale timestamp baked into the repeatable job payload. + const scheduledAtDate = new Date() + const startTime = Date.now() + + const scheduleRecord = await this.appDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId }) + if (!scheduleRecord) { + logger.error(`[ScheduleQueue]: Schedule record ${scheduleRecordId} not found, skipping job`) + return + } + if (!scheduleRecord.enabled) { + logger.debug(`[ScheduleQueue]: Schedule record ${scheduleRecordId} is disabled, skipping job`) + return + } + + // Create an initial log entry + const log = await scheduleService.createTriggerLog({ + appDataSource: this.appDataSource, + scheduleRecordId, + triggerType: scheduleRecord.triggerType ?? ScheduleTriggerType.AGENTFLOW, + targetId, + status: ScheduleTriggerStatus.RUNNING, + scheduledAt: scheduledAtDate, + workspaceId + }) + + try { + // Load the chatflow + const chatflow = await this.appDataSource.getRepository(ChatFlow).findOneBy({ id: targetId }) + if (!chatflow) { + throw new Error(`ChatFlow ${targetId} not found`) + } + if (chatflow.type !== 'AGENTFLOW') { + throw new Error(`ChatFlow ${targetId} is not of type AGENTFLOW`) + } + + // Build minimal IncomingAgentflowInput + const chatId = uuidv4() + const incomingInput: IncomingAgentflowInput = { + question: defaultInput || '', + chatId, + streaming: false + } + + const result = await executeAgentFlow({ + componentNodes: this.componentNodes, + incomingInput, + chatflow, + chatId, + appDataSource: this.appDataSource, + telemetry: this.telemetry, + cachePool: this.cachePool, + usageCacheManager: this.usageCacheManager, + sseStreamer: this.redisPublisher, + baseURL: '', + isInternal: true, + uploadedFilesContent: '', + fileUploads: [], + isTool: true, // suppresses SSE streaming + workspaceId: chatflow.workspaceId ?? workspaceId, + orgId: '', + subscriptionId: '', + productId: '' + }) + + const elapsedTimeMs = Date.now() - startTime + const executionId: string | undefined = + result && typeof result === 'object' && 'executionId' in result ? (result as any).executionId : undefined + + await scheduleService.updateTriggerLog(this.appDataSource, log.id, { + status: ScheduleTriggerStatus.SUCCEEDED, + elapsedTimeMs, + executionId + }) + + await scheduleService.updateLastRunAt(this.appDataSource, scheduleRecordId, new Date()) + logger.debug(`[ScheduleQueue]: Completed job for schedule ${scheduleRecordId} (${elapsedTimeMs}ms)`) + return result + } catch (error) { + const elapsedTimeMs = Date.now() - startTime + const errMsg = error instanceof Error ? error.message : String(error) + logger.error(`[ScheduleQueue]: Job failed for schedule ${scheduleRecordId}: ${errMsg}`) + + await scheduleService.updateTriggerLog(this.appDataSource, log.id, { + status: ScheduleTriggerStatus.FAILED, + elapsedTimeMs, + error: errMsg + }) + + throw error + } + } + + /** + * Add a repeatable scheduled job using BullMQ's repeat options. + * BullMQ deduplicates repeatable jobs by jobId pattern β€” safe to call on every startup. + */ + public async upsertJobScheduler(record: ScheduleRecord): Promise { + const timezone = record.timezone ?? 'UTC' + const jobData: ScheduleAgentflowJobData = { + scheduleRecordId: record.id, + targetId: record.targetId, + cronExpression: record.cronExpression, + timezone: timezone, + defaultInput: record.defaultInput ?? undefined, + workspaceId: record.workspaceId, + scheduledAt: new Date().toISOString() + } + + const repeatOptions: RepeatOptions = { + pattern: record.cronExpression, + tz: timezone + } + await this.queue.upsertJobScheduler(`schedule:${record.id}`, repeatOptions, { + name: `schedule:${record.id}`, + data: jobData + }) + + logger.info(`[ScheduleQueue]: Registered repeatable job for schedule ${record.id} (${record.cronExpression})`) + } + + /** + * Remove a repeatable scheduled job from the queue. + */ + public async removeJobScheduler(scheduleRecordId: string): Promise { + try { + await this.queue.removeJobScheduler(`schedule:${scheduleRecordId}`) + logger.info(`[ScheduleQueue]: Removed repeatable job for schedule ${scheduleRecordId}`) + } catch (error) { + logger.warn(`[ScheduleQueue]: Could not remove repeatable job for schedule ${scheduleRecordId}: ${error}`) + } + } +} diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 9998ca57643..1c4adcae0f4 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -22,6 +22,9 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { utilGetUploadsConfig } from '../../utils/getUploadsConfig' import logger from '../../utils/logger' import { updateStorageUsage } from '../../utils/quotaUsage' +import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' +import scheduleService, { resolveScheduleCron } from '../../services/schedule' +import { ScheduleBeat } from '../../queue/ScheduleBeat' export const enum ChatflowErrorMessage { INVALID_CHATFLOW_TYPE = 'Invalid Chatflow Type', @@ -110,7 +113,7 @@ const deleteChatflow = async (chatflowId: string, orgId: string, workspaceId: st try { const appServer = getRunningExpressApp() - await getChatflowById(chatflowId, workspaceId) + const chatflow = await getChatflowById(chatflowId, workspaceId) const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).delete({ id: chatflowId }) @@ -126,6 +129,14 @@ const deleteChatflow = async (chatflowId: string, orgId: string, workspaceId: st // Delete all upsert history await appServer.AppDataSource.getRepository(UpsertHistory).delete({ chatflowid: chatflowId }) + // delete schedules related to the chatflow if it's an agentflow + if (chatflow.type === EnumChatflowType.AGENTFLOW) { + const existingRecord = await scheduleService.disableSchedulesForTarget(chatflow.id, ScheduleTriggerType.AGENTFLOW, workspaceId) + if (existingRecord) { + await ScheduleBeat.getInstance().onScheduleChanged(existingRecord.id, 'delete') + } + } + try { // Delete all uploads corresponding to this chatflow const { totalSize } = await removeFolderFromStorage(orgId, chatflowId) @@ -308,6 +319,36 @@ const saveChatflow = async ( dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).save(chatflow) } + // Check if the flow is agentflow and if it has a schedule node, if yes then notify the beat to sync the schedule + if (dbResponse.type === EnumChatflowType.AGENTFLOW) { + /*** Get chatflows and prepare data ***/ + const flowData = dbResponse.flowData + const parsedFlowData: IReactFlowObject = JSON.parse(flowData) + const nodes = (parsedFlowData.nodes || []).filter((node) => node.data.name !== 'stickyNoteAgentflow') + const startNode = nodes.find((node) => node.data.name === 'startAgentflow') + const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' | 'scheduleInput' + if (startInputType === 'scheduleInput') { + const cronResult = resolveScheduleCron(startNode?.data?.inputs ?? {}) + const scheduleTimezone = startNode?.data?.inputs?.scheduleTimezone || 'UTC' + const scheduleDefaultInput = startNode?.data?.inputs?.scheduleDefaultInput || '' + if (cronResult.valid) { + const record = await scheduleService.createOrUpdateSchedule({ + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: dbResponse.id, + nodeId: startNode?.id, + cronExpression: cronResult.cronExpression!, + timezone: scheduleTimezone, + enabled: true, + defaultInput: scheduleDefaultInput, + workspaceId + }) + + // Notify the beat to sync the schedule + await ScheduleBeat.getInstance().onScheduleChanged(record.id, 'upsert') + } + } + } + const productId = await appServer.identityManager.getProductIdFromSubscription(subscriptionId) await appServer.telemetry.sendTelemetry( @@ -372,6 +413,54 @@ const updateChatflow = async ( await _checkAndUpdateDocumentStoreUsage(newDbChatflow, chatflow.workspaceId) const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).save(newDbChatflow) + // Check if the flow is agentflow and if it has a schedule node, if yes then notify the beat to sync the schedule + let shouldDisableSchedule = false + if (dbResponse.type === EnumChatflowType.AGENTFLOW) { + const flowData = dbResponse.flowData + const parsedFlowData: IReactFlowObject = JSON.parse(flowData) + const nodes = (parsedFlowData.nodes || []).filter((node) => node.data.name !== 'stickyNoteAgentflow') + const startNode = nodes.find((node) => node.data.name === 'startAgentflow') + const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' | 'scheduleInput' + if (startInputType === 'scheduleInput') { + const cronResult = resolveScheduleCron(startNode?.data?.inputs ?? {}) + const scheduleTimezone = startNode?.data?.inputs?.scheduleTimezone || 'UTC' + const scheduleDefaultInput = startNode?.data?.inputs?.scheduleDefaultInput || '' + if (cronResult.valid) { + const record = await scheduleService.createOrUpdateSchedule({ + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: dbResponse.id, + nodeId: startNode?.id, + cronExpression: cronResult.cronExpression!, + timezone: scheduleTimezone, + enabled: true, + defaultInput: scheduleDefaultInput, + workspaceId + }) + + // Notify the beat to sync the schedule + await ScheduleBeat.getInstance().onScheduleChanged(record.id, 'upsert') + } else { + // If the schedule configuration is invalid, we should disable the existing schedule if it exists, + // to prevent potential issues caused by invalid schedule configuration + shouldDisableSchedule = true + } + } else { + // If the start node is not scheduleInput, then we need to disable the existing schedule if it exists + shouldDisableSchedule = true + } + + if (shouldDisableSchedule) { + const existingRecord = await scheduleService.disableSchedulesForTarget( + dbResponse.id, + ScheduleTriggerType.AGENTFLOW, + workspaceId + ) + if (existingRecord) { + await ScheduleBeat.getInstance().onScheduleChanged(existingRecord.id, 'delete') + } + } + } + return dbResponse } diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts new file mode 100644 index 00000000000..c3347917f61 --- /dev/null +++ b/packages/server/src/services/schedule/index.ts @@ -0,0 +1,412 @@ +import { StatusCodes } from 'http-status-codes' +import { v4 as uuidv4 } from 'uuid' +import { ScheduleRecord, ScheduleTriggerType } from '../../database/entities/ScheduleRecord' +import { ScheduleTriggerLog, ScheduleTriggerStatus } from '../../database/entities/ScheduleTriggerLog' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { getErrorMessage } from '../../errors/utils' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import logger from '../../utils/logger' +import { DataSource } from 'typeorm' + +export interface CreateScheduleInput { + triggerType: ScheduleTriggerType + targetId: string + nodeId?: string + cronExpression: string + timezone?: string + enabled?: boolean + defaultInput?: string + workspaceId: string +} + +export interface UpdateScheduleInput { + cronExpression?: string + timezone?: string + enabled?: boolean + defaultInput?: string +} + +/** + * Validates a cron expression and returns parsed info. + * Uses a lightweight regex-based check without external dependencies. + * + * Supports standard 5-field cron: minute hour day month weekday + */ +export const validateCronExpression = (expression: string, timezone: string = 'UTC'): { valid: boolean; error?: string } => { + if (!expression || typeof expression !== 'string') { + return { valid: false, error: 'Cron expression must be a non-empty string' } + } + + const trimmed = expression.trim() + const fields = trimmed.split(/\s+/) + + if (fields.length !== 5 && fields.length !== 6) { + return { + valid: false, + error: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)' + } + } + + // Validate timezone + try { + Intl.DateTimeFormat('en-US', { timeZone: timezone }) + } catch { + return { valid: false, error: `Invalid timezone: ${timezone}` } + } + + // Returns true if s is a valid integer in [min, max] or a valid range "start-end" + const isValidRangeOrNumber = (s: string, min: number, max: number): boolean => { + const dashIdx = s.indexOf('-') + if (dashIdx !== -1) { + const startStr = s.slice(0, dashIdx) + const endStr = s.slice(dashIdx + 1) + if (!/^\d+$/.test(startStr) || !/^\d+$/.test(endStr)) return false + const start = parseInt(startStr, 10) + const end = parseInt(endStr, 10) + return start >= min && start <= max && end >= min && end <= max && start <= end + } + if (!/^\d+$/.test(s)) return false + const n = parseInt(s, 10) + return n >= min && n <= max + } + + // Validate a single cron field: supports *, numbers, ranges (n-m), steps (*/s, n/s, n-m/s), and comma-separated lists + const validateCronField = (field: string, min: number, max: number): boolean => { + const parts = field.split(',') + if (parts.some((p) => p === '')) return false // catches leading/trailing/consecutive commas + + for (const part of parts) { + const slashIdx = part.indexOf('/') + if (slashIdx !== -1) { + const base = part.slice(0, slashIdx) + const stepStr = part.slice(slashIdx + 1) + if (!/^\d+$/.test(stepStr)) return false + const step = parseInt(stepStr, 10) + if (step < 1) return false + // Base must be *, a plain number, or a range + if (base !== '*' && !isValidRangeOrNumber(base, min, max)) return false + } else if (part !== '*') { + if (!isValidRangeOrNumber(part, min, max)) return false + } + } + return true + } + + // Per-position field ranges [min, max]: minute hour day-of-month month day-of-week + const fieldRanges: Array<[number, number]> = [ + [0, 59], // minutes (or seconds when 6-field) + [0, 23], // hours + [1, 31], // day of month + [1, 12], // month + [0, 7] // day of week (0 and 7 both represent Sunday) + ] + + // For 6-field cron, prepend an extra seconds range (same as minutes: 0-59) + const ranges: Array<[number, number]> = fields.length === 6 ? [[0, 59], ...fieldRanges] : fieldRanges + for (let i = 0; i < fields.length; i++) { + if (!validateCronField(fields[i], ranges[i][0], ranges[i][1])) { + return { valid: false, error: `Invalid cron field at position ${i + 1}: "${fields[i]}"` } + } + } + + return { valid: true } +} + +const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise => { + try { + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ScheduleRecord) + + const validation = validateCronExpression(input.cronExpression, input.timezone ?? 'UTC') + if (!validation.valid) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, validation.error!) + } + + // Upsert: find existing record for this target + triggerType + let existing = await repo.findOne({ + where: { + targetId: input.targetId, + triggerType: input.triggerType, + workspaceId: input.workspaceId + } + }) + + if (existing) { + existing.cronExpression = input.cronExpression + existing.timezone = input.timezone ?? 'UTC' + if (input.enabled !== undefined) existing.enabled = input.enabled + if (input.defaultInput !== undefined) existing.defaultInput = input.defaultInput + if (input.nodeId !== undefined) existing.nodeId = input.nodeId + const saved = await repo.save(existing) + logger.debug(`[ScheduleService]: Updated schedule ${saved.id} for ${input.triggerType}:${input.targetId}`) + return saved + } + + const record = repo.create({ + id: uuidv4(), + triggerType: input.triggerType, + targetId: input.targetId, + nodeId: input.nodeId, + cronExpression: input.cronExpression, + timezone: input.timezone ?? 'UTC', + enabled: input.enabled !== undefined ? input.enabled : true, + defaultInput: input.defaultInput, + workspaceId: input.workspaceId + }) + + const saved = await repo.save(record) + logger.debug(`[ScheduleService]: Created schedule ${saved.id} for ${input.triggerType}:${input.targetId}`) + return saved + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.createOrUpdateSchedule - ${getErrorMessage(error)}` + ) + } +} + +const disableSchedulesForTarget = async ( + targetId: string, + triggerType: ScheduleTriggerType, + workspaceId: string +): Promise => { + try { + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ScheduleRecord) + const record = await repo.findOne({ where: { targetId, triggerType, workspaceId } }) + if (!record) return + record.enabled = false + await repo.save(record) + logger.debug(`[ScheduleService]: Disabled schedule for ${triggerType}:${targetId}`) + return record + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.disableSchedulesForTarget - ${getErrorMessage(error)}` + ) + } +} + +const getAllEnabledSchedules = async (): Promise => { + try { + const appServer = getRunningExpressApp() + return await appServer.AppDataSource.getRepository(ScheduleRecord).find({ + where: { enabled: true } + }) + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.getAllEnabledSchedules - ${getErrorMessage(error)}` + ) + } +} + +const updateLastRunAt = async (appDataSource: DataSource, scheduleRecordId: string, lastRunAt: Date): Promise => { + try { + await appDataSource.getRepository(ScheduleRecord).update({ id: scheduleRecordId }, { lastRunAt }) + } catch (error) { + logger.error(`[ScheduleService]: updateLastRunAt failed for ${scheduleRecordId}: ${getErrorMessage(error)}`) + } +} + +// ─── Log functions ───────────────────────────────────────────────────────────── + +const createTriggerLog = async (data: { + appDataSource: DataSource + scheduleRecordId: string + triggerType: ScheduleTriggerType + targetId: string + status: ScheduleTriggerStatus + scheduledAt: Date + workspaceId: string + executionId?: string + error?: string + elapsedTimeMs?: number +}): Promise => { + try { + const repo = data.appDataSource.getRepository(ScheduleTriggerLog) + const log = repo.create({ + id: uuidv4(), + ...data + }) + return await repo.save(log) + } catch (error) { + logger.error(`[ScheduleService]: createTriggerLog failed: ${getErrorMessage(error)}`) + throw error + } +} + +const updateTriggerLog = async ( + appDataSource: DataSource, + logId: string, + update: { status: ScheduleTriggerStatus; error?: string; elapsedTimeMs?: number; executionId?: string } +): Promise => { + try { + await appDataSource.getRepository(ScheduleTriggerLog).update({ id: logId }, update) + } catch (error) { + logger.error(`[ScheduleService]: updateTriggerLog failed for ${logId}: ${getErrorMessage(error)}`) + } +} + +// ─── Visual Picker helpers ────────────────────────────────────────────────── + +export interface VisualPickerInput { + scheduleFrequency: 'hourly' | 'daily' | 'weekly' | 'monthly' + scheduleOnMinute?: string | number + scheduleOnTime?: string // "HH:mm" + scheduleOnDayOfWeek?: string // comma-separated "1,3,5" (1=Mon … 7=Sun) + scheduleOnDayOfMonth?: string // comma-separated "1,15" +} + +/** + * Validate the visual-picker fields and return errors (if any). + */ +export const validateVisualPickerFields = (input: VisualPickerInput): { valid: boolean; error?: string } => { + const { scheduleFrequency, scheduleOnMinute, scheduleOnTime, scheduleOnDayOfWeek, scheduleOnDayOfMonth } = input + + if (!scheduleFrequency) { + return { valid: false, error: 'Frequency is required' } + } + if (!['hourly', 'daily', 'weekly', 'monthly'].includes(scheduleFrequency)) { + return { valid: false, error: `Invalid frequency: ${scheduleFrequency}` } + } + + if (scheduleFrequency === 'hourly') { + const minute = Number(scheduleOnMinute) + if (scheduleOnMinute === undefined || scheduleOnMinute === '' || isNaN(minute)) { + return { valid: false, error: 'On Minute is required for hourly frequency' } + } + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + return { valid: false, error: 'On Minute must be an integer between 0 and 59' } + } + } + + if (['daily', 'weekly', 'monthly'].includes(scheduleFrequency)) { + if (!scheduleOnTime) { + return { valid: false, error: 'On Time is required for daily/weekly/monthly frequency' } + } + if (!/^\d{2}:\d{2}$/.test(scheduleOnTime)) { + return { valid: false, error: 'On Time must be in HH:mm format' } + } + const [h, m] = scheduleOnTime.split(':').map(Number) + if (h < 0 || h > 23 || m < 0 || m > 59) { + return { valid: false, error: 'On Time contains out-of-range values' } + } + } + + if (scheduleFrequency === 'weekly') { + if (!scheduleOnDayOfWeek) { + return { valid: false, error: 'On Day of Week is required for weekly frequency' } + } + const days = scheduleOnDayOfWeek + .split(',') + .map((d) => d.trim()) + .filter((d) => d !== '') + for (const d of days) { + const n = Number(d) + if (isNaN(n) || !Number.isInteger(n) || n < 0 || n > 7) { + return { valid: false, error: `Invalid day of week value: ${d} (expected 0-7)` } + } + } + } + + if (scheduleFrequency === 'monthly') { + if (!scheduleOnDayOfMonth) { + return { valid: false, error: 'On Day of Month is required for monthly frequency' } + } + const days = scheduleOnDayOfMonth + .split(',') + .map((d) => d.trim()) + .filter((d) => d !== '') + for (const d of days) { + const n = Number(d) + if (isNaN(n) || !Number.isInteger(n) || n < 1 || n > 31) { + return { valid: false, error: `Invalid day of month value: ${d} (expected 1-31)` } + } + } + } + + return { valid: true } +} + +/** + * Convert visual-picker fields into a standard 5-field cron expression. + * Assumes fields have already been validated via validateVisualPickerFields. + */ +export const buildCronFromVisualPicker = (input: VisualPickerInput): string => { + const { scheduleFrequency, scheduleOnMinute, scheduleOnTime, scheduleOnDayOfWeek, scheduleOnDayOfMonth } = input + + switch (scheduleFrequency) { + case 'hourly': { + // " * * * *" + return `${Number(scheduleOnMinute)} * * * *` + } + case 'daily': { + const [h, m] = scheduleOnTime!.split(':').map(Number) + return `${m} ${h} * * *` + } + case 'weekly': { + const [h, m] = scheduleOnTime!.split(':').map(Number) + return `${m} ${h} * * ${scheduleOnDayOfWeek}` + } + case 'monthly': { + const [h, m] = scheduleOnTime!.split(':').map(Number) + return `${m} ${h} ${scheduleOnDayOfMonth} * *` + } + default: + throw new Error(`Unsupported frequency: ${scheduleFrequency}`) + } +} + +/** + * Unified helper: resolves the cron expression from a Start node's inputs, + * handling both "cronExpression" and "visualPicker" schedule types. + * Returns { valid, cronExpression?, error? }. + */ +export const resolveScheduleCron = (inputs: Record): { valid: boolean; cronExpression?: string; error?: string } => { + const scheduleType = (inputs.scheduleType as string) || 'cronExpression' + const timezone = (inputs.scheduleTimezone as string) || 'UTC' + + if (scheduleType === 'visualPicker') { + const pickerInput: VisualPickerInput = { + scheduleFrequency: inputs.scheduleFrequency, + scheduleOnMinute: inputs.scheduleOnMinute, + scheduleOnTime: inputs.scheduleOnTime, + scheduleOnDayOfWeek: inputs.scheduleOnDayOfWeek, + scheduleOnDayOfMonth: inputs.scheduleOnDayOfMonth + } + const pickerResult = validateVisualPickerFields(pickerInput) + if (!pickerResult.valid) { + return { valid: false, error: pickerResult.error } + } + const cron = buildCronFromVisualPicker(pickerInput) + // Also validate the resulting cron + timezone + const cronResult = validateCronExpression(cron, timezone) + if (!cronResult.valid) { + return { valid: false, error: cronResult.error } + } + return { valid: true, cronExpression: cron } + } + + // scheduleType === 'cronExpression' + const expression = inputs.scheduleCronExpression as string + const cronResult = validateCronExpression(expression, timezone) + if (!cronResult.valid) { + return { valid: false, error: cronResult.error } + } + return { valid: true, cronExpression: expression } +} + +export default { + validateCronExpression, + validateVisualPickerFields, + buildCronFromVisualPicker, + resolveScheduleCron, + createOrUpdateSchedule, + disableSchedulesForTarget, + getAllEnabledSchedules, + updateLastRunAt, + createTriggerLog, + updateTriggerLog +} diff --git a/packages/server/src/services/validation/index.ts b/packages/server/src/services/validation/index.ts index 85cde6467d0..4bb85f68be0 100644 --- a/packages/server/src/services/validation/index.ts +++ b/packages/server/src/services/validation/index.ts @@ -5,6 +5,7 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { ChatFlow } from '../../database/entities/ChatFlow' import { INodeParams } from 'flowise-components' import { IReactFlowEdge, IReactFlowNode } from '../../Interface' +import { resolveScheduleCron } from '../schedule' interface IValidationResult { id: string @@ -250,6 +251,14 @@ const checkFlowValidation = async (flowId: string, workspaceId?: string): Promis } } + // Validate schedule configuration on startAgentflow nodes with scheduleInput + if (node.data.name === 'startAgentflow' && node.data.inputs?.startInputType === 'scheduleInput') { + const cronResult = resolveScheduleCron(node.data.inputs) + if (!cronResult.valid) { + nodeIssues.push(`Schedule configuration is invalid: ${cronResult.error}`) + } + } + // Add node to validation results if it has issues if (nodeIssues.length > 0) { validationResults.push({ diff --git a/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx new file mode 100644 index 00000000000..0a260db6432 --- /dev/null +++ b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { Box, Chip } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1) + +export const MonthDaysPicker = ({ value, onChange, disabled = false }) => { + const theme = useTheme() + + const parseValue = (val) => { + if (!val) return [] + if (Array.isArray(val)) return val.map(String) + if (typeof val === 'string') + return val + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return [] + } + + const [selected, setSelected] = useState(parseValue(value)) + + useEffect(() => { + setSelected(parseValue(value)) + }, [value]) + + const toggle = (day) => { + if (disabled) return + const dayStr = String(day) + let next + if (selected.includes(dayStr)) { + next = selected.filter((d) => d !== dayStr) + } else { + next = [...selected, dayStr] + } + next.sort((a, b) => Number(a) - Number(b)) + setSelected(next) + onChange(next.join(',')) + } + + return ( + + {DAYS_OF_MONTH.map((day) => { + const dayStr = String(day) + const isSelected = selected.includes(dayStr) + return ( + toggle(day)} + sx={{ + cursor: disabled ? 'default' : 'pointer', + minWidth: 32, + fontWeight: isSelected ? 600 : 400, + borderWidth: '1.5px', + borderStyle: 'solid', + borderColor: isSelected ? theme.palette.primary.main : theme.palette.grey[400], + backgroundColor: isSelected ? theme.palette.primary.main + '20' : 'transparent', + color: isSelected ? theme.palette.primary.main : theme.palette.text.primary, + '&:hover': disabled + ? {} + : { + backgroundColor: isSelected ? theme.palette.primary.main + '35' : theme.palette.grey[200] + } + }} + /> + ) + })} + + ) +} + +MonthDaysPicker.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool +} diff --git a/packages/ui/src/ui-component/picker/TimePicker.jsx b/packages/ui/src/ui-component/picker/TimePicker.jsx new file mode 100644 index 00000000000..e072166b1ed --- /dev/null +++ b/packages/ui/src/ui-component/picker/TimePicker.jsx @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { Box, TextField } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +export const TimePicker = ({ value, onChange, disabled = false, placeholder = '09:00' }) => { + const theme = useTheme() + const [timeValue, setTimeValue] = useState(value || '') + + useEffect(() => { + setTimeValue(value || '') + }, [value]) + + const handleChange = (e) => { + const newValue = e.target.value + setTimeValue(newValue) + onChange(newValue) + } + + return ( + + + + ) +} + +TimePicker.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + placeholder: PropTypes.string +} diff --git a/packages/ui/src/ui-component/picker/WeekDaysPicker.jsx b/packages/ui/src/ui-component/picker/WeekDaysPicker.jsx new file mode 100644 index 00000000000..b127ec1ac1d --- /dev/null +++ b/packages/ui/src/ui-component/picker/WeekDaysPicker.jsx @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { Box, Chip } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +const DEFAULT_DAYS = [ + { label: 'Mon', value: '1' }, + { label: 'Tue', value: '2' }, + { label: 'Wed', value: '3' }, + { label: 'Thu', value: '4' }, + { label: 'Fri', value: '5' }, + { label: 'Sat', value: '6' }, + { label: 'Sun', value: '7' } +] + +export const WeekDaysPicker = ({ value, options, onChange, disabled = false }) => { + const theme = useTheme() + const days = options?.length ? options.map((o) => ({ label: o.label, value: o.name })) : DEFAULT_DAYS + + const parseValue = (val) => { + if (!val) return [] + if (Array.isArray(val)) return val + if (typeof val === 'string') + return val + .split(',') + .map((token) => token.trim()) + .filter(Boolean) + return [] + } + + const [selected, setSelected] = useState(parseValue(value)) + + useEffect(() => { + setSelected(parseValue(value)) + }, [value]) + + const toggle = (dayValue) => { + if (disabled) return + let next + if (selected.includes(dayValue)) { + next = selected.filter((d) => d !== dayValue) + } else { + next = [...selected, dayValue] + } + // Sort by the days array order + next.sort((a, b) => days.findIndex((d) => d.value === a) - days.findIndex((d) => d.value === b)) + setSelected(next) + onChange(next.join(',')) + } + + return ( + + {days.map((day) => { + const isSelected = selected.includes(day.value) + return ( + toggle(day.value)} + sx={{ + cursor: disabled ? 'default' : 'pointer', + fontWeight: isSelected ? 600 : 400, + borderWidth: '1.5px', + borderStyle: 'solid', + borderColor: isSelected ? theme.palette.primary.main : theme.palette.grey[400], + backgroundColor: isSelected ? theme.palette.primary.main + '20' : 'transparent', + color: isSelected ? theme.palette.primary.main : theme.palette.text.primary, + '&:hover': disabled + ? {} + : { + backgroundColor: isSelected ? theme.palette.primary.main + '35' : theme.palette.grey[200] + } + }} + /> + ) + })} + + ) +} + +WeekDaysPicker.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + name: PropTypes.string + }) + ), + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool +} diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index ac834c77f19..a6d0bc3ab12 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -137,7 +137,10 @@ export const initNode = (nodeData, newNodeId, isAgentflow) => { 'file', 'folder', 'tabs', - 'conditionFunction' // This is a special type for condition functions + 'conditionFunction', // This is a special type for condition functions + 'timePicker', + 'weekDaysPicker', + 'monthDaysPicker' ] // Inputs diff --git a/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx index 3205612eefc..568df3e7ad4 100644 --- a/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx +++ b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx @@ -26,7 +26,10 @@ import { IconWorldWww, IconPhoto, IconBrandGoogle, - IconBrowserCheck + IconBrowserCheck, + IconMessageCircle, + IconClockHour4, + IconForms } from '@tabler/icons-react' import StopCircleIcon from '@mui/icons-material/StopCircle' import CancelIcon from '@mui/icons-material/Cancel' @@ -398,6 +401,40 @@ const AgentFlowNode = ({ data }) => { {data.label} + {/* Render the icon for "Start" node to help users determine it's started by user's input or schedule */} + {data.name === 'startAgentflow' && + data.inputs?.startInputType && + (() => { + const inputType = data.inputs.startInputType + const iconMap = { + chatInput: { icon: }, + formInput: { icon: }, + scheduleInput: { icon: } + } + const info = iconMap[inputType] + if (!info) return null + return ( + + + {info.icon} + + + ) + })()} + {(() => { // Array of model configs to check and render const modelConfigs = [ diff --git a/packages/ui/src/views/canvas/NodeInputHandler.jsx b/packages/ui/src/views/canvas/NodeInputHandler.jsx index 04175e0872b..7db2c1a0540 100644 --- a/packages/ui/src/views/canvas/NodeInputHandler.jsx +++ b/packages/ui/src/views/canvas/NodeInputHandler.jsx @@ -48,6 +48,9 @@ import { Tab } from '@/ui-component/tabs/Tab' import { ConfigInput } from '@/views/agentflowsv2/ConfigInput' import { BackdropLoader } from '@/ui-component/loading/BackdropLoader' import DocStoreInputHandler from '@/views/docstore/DocStoreInputHandler' +import { TimePicker } from '@/ui-component/picker/TimePicker' +import { WeekDaysPicker } from '@/ui-component/picker/WeekDaysPicker' +import { MonthDaysPicker } from '@/ui-component/picker/MonthDaysPicker' import ToolDialog from '@/views/tools/ToolDialog' import AssistantDialog from '@/views/assistants/openai/AssistantDialog' @@ -1212,6 +1215,29 @@ const NodeInputHandler = ({ )} + {inputParam.type === 'timePicker' && ( + handleDataChange({ inputParam, newValue })} + /> + )} + {inputParam.type === 'weekDaysPicker' && ( + handleDataChange({ inputParam, newValue })} + /> + )} + {inputParam.type === 'monthDaysPicker' && ( + handleDataChange({ inputParam, newValue })} + /> + )} {inputParam.type === 'array' && } {/* CUSTOM INPUT LOGIC */} {inputParam.type.includes('conditionFunction') && ( From 529b35dcdecffb8abef31bda94cb8e7bbae9fae0 Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Sat, 21 Mar 2026 14:21:40 +0700 Subject: [PATCH 02/15] feat: implement schedule management for agentflows - Refactor ScheduleQueue to utilize executeScheduleJob for job execution. - Add new endpoints for schedule status retrieval and enabling/disabling schedules in chatflows. - Update chatflow service to handle schedule creation, updates, and deletions more effectively. - Introduce DatePicker component for date input in UI. - Enhance CanvasHeader to manage schedule state and toggle functionality. - Integrate schedule validation logic to ensure proper configuration before enabling schedules. - Update dependencies to include node-cron for improved scheduling capabilities. --- .../components/nodes/agentflow/Start/Start.ts | 12 +- packages/server/package.json | 1 + .../server/src/controllers/chatflows/index.ts | 54 ++- .../src/database/entities/ScheduleRecord.ts | 8 +- .../1772000000000-AddScheduleEntities.ts | 1 + .../1772000000000-AddScheduleEntities.ts | 1 + .../1772000000000-AddScheduleEntities.ts | 1 + .../1772000000000-AddScheduleEntities.ts | 1 + packages/server/src/queue/ScheduleBeat.ts | 366 +++++++----------- packages/server/src/queue/ScheduleExecutor.ts | 212 ++++++++++ packages/server/src/queue/ScheduleQueue.ts | 119 ++---- packages/server/src/routes/chatflows/index.ts | 8 + .../server/src/services/chatflows/index.ts | 80 ++-- .../server/src/services/schedule/index.ts | 321 +++++++++++++-- packages/ui/src/api/chatflows.js | 8 +- .../ui/src/ui-component/picker/DatePicker.jsx | 58 +++ packages/ui/src/utils/genericHelper.js | 3 +- packages/ui/src/views/canvas/CanvasHeader.jsx | 128 +++++- .../ui/src/views/canvas/NodeInputHandler.jsx | 9 + pnpm-lock.yaml | 9 + 20 files changed, 994 insertions(+), 406 deletions(-) create mode 100644 packages/server/src/queue/ScheduleExecutor.ts create mode 100644 packages/ui/src/ui-component/picker/DatePicker.jsx diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 2cb8c348281..1f1cc3cd09f 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -236,6 +236,16 @@ class Start_Agentflow implements INode { scheduleFrequency: 'monthly' } }, + { + label: 'End Date', + name: 'scheduleEndDate', + type: 'datePicker', + description: 'Optional date after which the schedule will stop firing.', + optional: true, + show: { + startInputType: 'scheduleInput' + } + }, { label: 'Timezone', name: 'scheduleTimezone', @@ -253,7 +263,6 @@ class Start_Agentflow implements INode { type: 'string', placeholder: 'Run the daily report', description: 'Default question/input passed to the flow when it is triggered by the scheduler.', - acceptVariable: true, rows: 4, show: { startInputType: 'scheduleInput' @@ -352,7 +361,6 @@ class Start_Agentflow implements INode { const effectiveInput = (typeof input === 'string' && input) || defaultInput || '' inputData.question = effectiveInput outputData.question = effectiveInput - outputData.scheduledAt = options.agentflowRuntime?.scheduledAt ?? new Date().toISOString() } if (startEphemeralMemory) { diff --git a/packages/server/package.json b/packages/server/package.json index afe6201e021..51a96f9b7a9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -126,6 +126,7 @@ "multer-s3": "^3.0.1", "mysql2": "^3.11.3", "nanoid": "3", + "node-cron": "^4.2.1", "nodemailer": "^7.0.7", "openai": "6.19.0", "passport": "^0.7.0", diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index a6125ca57c5..033d66cbd44 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -13,6 +13,8 @@ import { WorkspaceUserErrorMessage, WorkspaceUserService } from '../../enterpris import { QueryRunner } from 'typeorm' import { GeneralErrorMessage } from '../../utils/constants' import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData' +import scheduleService from '../../services/schedule' +import { ScheduleBeat } from '../../queue/ScheduleBeat' const checkIfChatflowIsValidForStreaming = async (req: Request, res: Response, next: NextFunction) => { try { @@ -273,6 +275,54 @@ const checkIfChatflowHasChanged = async (req: Request, res: Response, next: Next } } +const getScheduleStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params?.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: chatflowsController.getScheduleStatus - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: chatflowsController.getScheduleStatus - workspace not found!') + } + const status = await scheduleService.getScheduleStatus(req.params.id, workspaceId) + return res.json({ + enabled: status.record?.enabled ?? false, + canEnable: status.canEnable, + reason: status.reason, + record: status.record + }) + } catch (error) { + next(error) + } +} + +const toggleScheduleEnabled = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params?.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: chatflowsController.toggleScheduleEnabled - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: chatflowsController.toggleScheduleEnabled - workspace not found!') + } + const { enabled } = req.body + if (typeof enabled !== 'boolean') { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, '"enabled" must be a boolean') + } + const record = await scheduleService.toggleScheduleEnabled(req.params.id, workspaceId, enabled) + await ScheduleBeat.getInstance().onScheduleChanged(record.id, enabled ? 'upsert' : 'delete') + return res.json(record) + } catch (error) { + next(error) + } +} + export default { checkIfChatflowIsValidForStreaming, checkIfChatflowIsValidForUploads, @@ -284,5 +334,7 @@ export default { updateChatflow, getSinglePublicChatflow, getSinglePublicChatbotConfig, - checkIfChatflowHasChanged + checkIfChatflowHasChanged, + getScheduleStatus, + toggleScheduleEnabled } diff --git a/packages/server/src/database/entities/ScheduleRecord.ts b/packages/server/src/database/entities/ScheduleRecord.ts index 18491bb40ee..b9c05d0a72a 100644 --- a/packages/server/src/database/entities/ScheduleRecord.ts +++ b/packages/server/src/database/entities/ScheduleRecord.ts @@ -40,10 +40,14 @@ export class ScheduleRecord { defaultInput?: string @Column({ nullable: true, type: 'timestamp' }) - lastRunAt?: Date + lastRunAt?: Date | null @Column({ nullable: true, type: 'timestamp' }) - nextRunAt?: Date + nextRunAt?: Date | null + + /** Optional date/time after which the schedule will no longer fire */ + @Column({ nullable: true, type: 'timestamp' }) + endDate?: Date | null @Column({ type: 'varchar' }) workspaceId: string diff --git a/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts index e4419732ce8..e5e398cb2ff 100644 --- a/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts @@ -14,6 +14,7 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { \`defaultInput\` text, \`lastRunAt\` datetime(6), \`nextRunAt\` datetime(6), + \`endDate\` datetime(6), \`workspaceId\` varchar(255) NOT NULL, \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), diff --git a/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts index bb106c43202..d40fbe8315e 100644 --- a/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts @@ -14,6 +14,7 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { \`defaultInput\` text, \`lastRunAt\` datetime(6), \`nextRunAt\` datetime(6), + \`endDate\` datetime(6), \`workspaceId\` varchar(255) NOT NULL, \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), diff --git a/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts index 5a2605236cd..c864c2ebf46 100644 --- a/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts @@ -14,6 +14,7 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { "defaultInput" text, "lastRunAt" timestamp, "nextRunAt" timestamp, + "endDate" timestamp, "workspaceId" varchar NOT NULL, "createdDate" timestamp NOT NULL DEFAULT now(), "updatedDate" timestamp NOT NULL DEFAULT now(), diff --git a/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts index ba892275cf2..fa42d2c62d7 100644 --- a/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts @@ -14,6 +14,7 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { "defaultInput" text, "lastRunAt" datetime, "nextRunAt" datetime, + "endDate" datetime, "workspaceId" varchar NOT NULL, "createdDate" datetime NOT NULL DEFAULT (datetime('now')), "updatedDate" datetime NOT NULL DEFAULT (datetime('now')) diff --git a/packages/server/src/queue/ScheduleBeat.ts b/packages/server/src/queue/ScheduleBeat.ts index 1e7847edde8..f58c9f3741f 100644 --- a/packages/server/src/queue/ScheduleBeat.ts +++ b/packages/server/src/queue/ScheduleBeat.ts @@ -4,115 +4,29 @@ * Responsible for keeping BullMQ repeatable jobs (or in-process timers) * in sync with the ScheduleRecord table. * - * Queue mode : delegates scheduling to BullMQ repeat jobs via ScheduleQueue. - * Non-queue mode: uses setInterval-based in-process timers (1-minute resolution). + * Queue mode : delegates scheduling to BullMQ repeat jobs via ScheduleQueue. + * Non-queue mode: uses node-cron to register per-schedule cron jobs in-process. * * Either way, ScheduleBeat.init() must be called once after the DB is ready. */ import { getRunningExpressApp } from '../utils/getRunningExpressApp' import { ScheduleRecord } from '../database/entities/ScheduleRecord' -import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' import { ScheduleQueue } from './ScheduleQueue' import { QueueManager } from './QueueManager' +import { executeScheduleJob } from './ScheduleExecutor' import scheduleService from '../services/schedule' -import { executeAgentFlow } from '../utils/buildAgentflow' -import { IncomingAgentflowInput, MODE } from '../Interface' -import { ChatFlow } from '../database/entities/ChatFlow' -import { v4 as uuidv4 } from 'uuid' +import { MODE } from '../Interface' import logger from '../utils/logger' - -// --------------------------------------------------------------------------- -// Minimal cron-expression parser for non-queue mode -// Returns true if the given Date matches the cron pattern (minute resolution) -// Supports: * and */step, individual values, ranges (a-b), and comma lists -// --------------------------------------------------------------------------- -function matchesCronField(field: string, value: number, min: number, max: number): boolean { - if (field === '*') return true - - for (const part of field.split(',')) { - if (part.includes('/')) { - const [rangeStr, stepStr] = part.split('/') - const step = parseInt(stepStr, 10) - if (isNaN(step)) continue - if (rangeStr === '*') { - if ((value - min) % step === 0) return true - } else if (rangeStr.includes('-')) { - const [start, end] = rangeStr.split('-').map(Number) - if (value >= start && value <= end && (value - start) % step === 0) return true - } else { - if (value === parseInt(rangeStr, 10)) return true - } - } else if (part.includes('-')) { - const [start, end] = part.split('-').map(Number) - if (value >= start && value <= end) return true - } else { - if (value === parseInt(part, 10)) return true - } - } - return false -} - -function matchesCron(expression: string, date: Date, timezone: string = 'UTC'): boolean { - // Extract date/time fields in the target timezone using Intl.DateTimeFormat.formatToParts() - // to avoid locale/DST-dependent round-tripping through Date parsing. - let minute: number, hour: number, dom: number, month: number, dow: number - try { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - weekday: 'short', - hour12: false - }).formatToParts(date) - const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) - const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun' - minute = get('minute') - hour = get('hour') % 24 // hour12:false returns 24 for midnight in some runtimes - dom = get('day') - month = get('month') - dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayStr) - if (dow === -1) dow = date.getUTCDay() // safety fallback - } catch { - // Fallback to UTC fields if the timezone identifier is invalid - minute = date.getUTCMinutes() - hour = date.getUTCHours() - dom = date.getUTCDate() - month = date.getUTCMonth() + 1 - dow = date.getUTCDay() - } - - const fields = expression.trim().split(/\s+/) - // Support 5-field (standard) and 6-field (with seconds) cron - const offset = fields.length === 6 ? 1 : 0 - const minuteField = fields[0 + offset] - const hourField = fields[1 + offset] - const domField = fields[2 + offset] - const monthField = fields[3 + offset] - const dowField = fields[4 + offset] - - // Cron allows both 0 and 7 for Sunday; Date.getDay() only returns 0. - // When dow is 0 (Sunday), also evaluate the field against 7 so expressions - // like "7", "1-7", or "5-7" match correctly. - const dowMatches = matchesCronField(dowField, dow, 0, 7) || (dow === 0 && matchesCronField(dowField, 7, 0, 7)) - return ( - matchesCronField(minuteField, minute, 0, 59) && - matchesCronField(hourField, hour, 0, 23) && - matchesCronField(domField, dom, 1, 31) && - matchesCronField(monthField, month, 1, 12) && - dowMatches - ) -} +import cron, { ScheduledTask } from 'node-cron' // --------------------------------------------------------------------------- export class ScheduleBeat { private static instance: ScheduleBeat - private inProcessTimer: NodeJS.Timeout | null = null private isQueueMode: boolean + /** Map of scheduleRecordId β†’ node-cron ScheduledTask (non-queue mode only) */ + private cronJobs: Map = new Map() private constructor() { this.isQueueMode = process.env.MODE === MODE.QUEUE @@ -128,7 +42,7 @@ export class ScheduleBeat { /** * Initialize scheduling. Must be called after the DB is initialized. * - * NOTE: In non-queue mode, schedules are executed via in-process timers without + * NOTE: In non-queue mode, schedules are executed via in-process cron jobs without * any distributed locking or leader election. If the API is deployed with * multiple replicas and all of them call ScheduleBeat.init(), each replica * will run the same schedules, causing duplicate executions. For High Availability (HA) / multi- @@ -136,45 +50,34 @@ export class ScheduleBeat { */ public async init(): Promise { logger.info(`[ScheduleBeat]: Initializing in ${this.isQueueMode ? 'queue' : 'non-queue'} mode`) - if (this.isQueueMode) { - await this._syncQueueModeSchedules() - } else { + if (!this.isQueueMode) { logger.warn( - '[ScheduleBeat]: Running in non-queue mode with in-process timers and no distributed locking. ' + + '[ScheduleBeat]: Running in non-queue mode with node-cron and no distributed locking. ' + 'If multiple API replicas are running, schedules will be executed once per replica. ' + 'For High Availability (HA) deployments, enable queue mode (MODE.QUEUE) to avoid duplicate executions.' ) - this._startInProcessTimer() } + await this._syncAllJobs() } /** - * Call this after a schedule is created/updated/deleted to resync the queue. - * In queue mode it re-registers the BullMQ repeatable job. - * In non-queue mode the in-process timer re-reads the DB on every tick, so no action needed. + * Call this after a schedule is created/updated/deleted to resync. + * Mode-agnostic β€” delegates to _removeJob / _upsertJob which dispatch + * to BullMQ (queue mode) or node-cron (non-queue mode). */ public async onScheduleChanged(scheduleRecordId: string, action: 'upsert' | 'delete'): Promise { - if (!this.isQueueMode) return // no-op; timer picks up DB changes automatically try { - // NOTE: Need to load the ScheduleRecord here to get the cron expression and timezone for removing the old job. - const appServer = getRunningExpressApp() - const scheduleRecord = await appServer.AppDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId }) - if (!scheduleRecord) { - logger.warn(`[ScheduleBeat]: Schedule record ${scheduleRecordId} not found for onScheduleChanged`) + if (action === 'delete') { + await this._removeJob(scheduleRecordId) return } - const scheduleQueue = QueueManager.getInstance().getQueue('schedule') as ScheduleQueue | undefined - if (!scheduleQueue) { - logger.warn('[ScheduleBeat]: ScheduleQueue not available β€” cannot sync schedule changes') - return - } - if (action === 'delete' || !scheduleRecord.enabled) { - await scheduleQueue.removeJobScheduler(scheduleRecord.id) + const appServer = getRunningExpressApp() + const scheduleRecord = await appServer.AppDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId }) + + if (!scheduleRecord || !scheduleRecord.enabled) { + await this._removeJob(scheduleRecordId) } else { - // Remove old job first (in case cron expression changed) - await scheduleQueue.removeJobScheduler(scheduleRecord.id) - // Add new job - await scheduleQueue.upsertJobScheduler(scheduleRecord) + await this._upsertJob(scheduleRecord) } } catch (error) { logger.error(`[ScheduleBeat]: onScheduleChanged error: ${error}`) @@ -185,143 +88,140 @@ export class ScheduleBeat { * Stop all scheduling activity (called on graceful shutdown). */ public async shutdown(): Promise { - if (this.inProcessTimer) { - clearInterval(this.inProcessTimer) - this.inProcessTimer = null + for (const [, task] of this.cronJobs) { + task.stop() } + this.cronJobs.clear() } - // ─── Queue mode ──────────────────────────────────────────────────────────── - - private async _syncQueueModeSchedules(): Promise { - try { - const scheduleQueue = QueueManager.getInstance().getQueue('schedule') as ScheduleQueue | undefined - if (!scheduleQueue) { - logger.warn('[ScheduleBeat]: ScheduleQueue not available β€” skipping sync') - return - } - - // NOTE: This naively re-registers all enabled schedules on every startup. - // BullMQ will deduplicate them by jobId, so this is safe but could be optimized by only upserting changed schedules if needed. - const records = await scheduleService.getAllEnabledSchedules() - for (const record of records) { - await scheduleQueue.upsertJobScheduler(record) - } - logger.info(`[ScheduleBeat]: Synced ${records.length} schedule(s) to BullMQ`) + // ─── Mode-agnostic job management ─────────────────────────────────────── - // NOTE: For the disabled schedules, we rely on the fact that ScheduleBeat.onScheduleChanged will be called - // on each schedule update to remove the corresponding repeatable job from BullMQ. - // This means that if a schedule is disabled but not deleted, its repeatable job will still be - // registered on startup but should be removed shortly after when onScheduleChanged is triggered. - // No need to explicitly remove disabled schedules here since they will be cleaned up by onScheduleChanged. - } catch (error) { - logger.error(`[ScheduleBeat]: Failed to sync schedules in queue mode: ${error}`) + /** + * Register (or re-register) a schedule job via the active backend. + */ + private async _upsertJob(record: ScheduleRecord): Promise { + if (this.isQueueMode) { + const scheduleQueue = this._getScheduleQueue() + if (!scheduleQueue) return + await scheduleQueue.upsertJobScheduler(record) + } else { + this._upsertCronJob(record) } } - // ─── Non-queue mode ──────────────────────────────────────────────────────── + /** + * Remove a schedule job from the active backend. + */ + private async _removeJob(scheduleRecordId: string): Promise { + if (this.isQueueMode) { + const scheduleQueue = this._getScheduleQueue() + if (!scheduleQueue) return + await scheduleQueue.removeJobScheduler(scheduleRecordId) + } else { + this._removeCronJob(scheduleRecordId) + } + } - private _startInProcessTimer(): void { - // Tick every 60 seconds, aligned to the start of the next minute - const now = Date.now() - const msToNextMinute = 60_000 - (now % 60_000) + /** + * Get the ScheduleQueue instance (queue mode only). Returns undefined with a warning if unavailable. + */ + private _getScheduleQueue(): ScheduleQueue | undefined { + const scheduleQueue = QueueManager.getInstance().getQueue('schedule') as ScheduleQueue | undefined + if (!scheduleQueue) { + logger.warn('[ScheduleBeat]: ScheduleQueue not available') + } + return scheduleQueue + } - setTimeout(() => { - this._tick() - this.inProcessTimer = setInterval(() => this._tick(), 60_000) - }, msToNextMinute) + /** + * Loads all enabled schedules in batches and registers them via the active backend. + */ + private async _syncAllJobs(): Promise { + // In non-queue mode, stop existing cron jobs first + if (!this.isQueueMode) { + for (const [, task] of this.cronJobs) { + task.stop() + } + this.cronJobs.clear() + } - logger.info(`[ScheduleBeat]: In-process cron timer will fire in ${Math.round(msToNextMinute / 1000)}s`) + let skip = 0 + let totalSynced = 0 + let batch: ScheduleRecord[] + do { + batch = await scheduleService.getEnabledSchedulesBatch(skip) + for (const record of batch) { + await this._upsertJob(record) + } + totalSynced += batch.length + skip += batch.length + } while (batch.length > 0) + logger.info(`[ScheduleBeat]: Synced ${totalSynced} schedule(s)`) } - private async _tick(): Promise { - const now = new Date() - logger.debug(`[ScheduleBeat]: Tick at ${now.toISOString()}`) + /** + * Register (or re-register) a node-cron job for a schedule record. + */ + private _upsertCronJob(record: ScheduleRecord): void { + this._removeCronJob(record.id) - let records: ScheduleRecord[] - try { - records = await scheduleService.getAllEnabledSchedules() - } catch (error) { - logger.error(`[ScheduleBeat]: Could not load schedules: ${error}`) + const tz = record.timezone ?? 'UTC' + + if (!cron.validate(record.cronExpression)) { + logger.warn(`[ScheduleBeat]: Invalid cron expression for schedule ${record.id}: "${record.cronExpression}", skipping`) return } - for (const record of records) { - if (matchesCron(record.cronExpression, now, record.timezone ?? 'UTC')) { - this._fireSchedule(record, now).catch((err) => { + const task = cron.schedule( + record.cronExpression, + () => { + this._onCronFire(record.id).catch((err) => { logger.error(`[ScheduleBeat]: Error firing schedule ${record.id}: ${err}`) }) - } + }, + { timezone: tz } + ) + + this.cronJobs.set(record.id, task) + logger.debug(`[ScheduleBeat]: Registered cron job for schedule ${record.id} (${record.cronExpression} ${tz})`) + } + + /** + * Stop and remove a node-cron job for a schedule record. + */ + private _removeCronJob(scheduleRecordId: string): void { + const existing = this.cronJobs.get(scheduleRecordId) + if (existing) { + existing.stop() + this.cronJobs.delete(scheduleRecordId) + logger.debug(`[ScheduleBeat]: Removed cron job for schedule ${scheduleRecordId}`) } } - private async _fireSchedule(record: ScheduleRecord, scheduledAt: Date): Promise { + /** + * Callback fired by node-cron. Delegates to the shared ScheduleExecutor + * with Beat-specific cleanup callbacks. + */ + private async _onCronFire(scheduleRecordId: string): Promise { const appServer = getRunningExpressApp() - const appDataSource = appServer.AppDataSource - const startTime = Date.now() - const log = await scheduleService.createTriggerLog({ - appDataSource, - scheduleRecordId: record.id, - triggerType: record.triggerType, - targetId: record.targetId, - status: ScheduleTriggerStatus.RUNNING, - scheduledAt, - workspaceId: record.workspaceId - }) - - try { - const chatflow = await appDataSource.getRepository(ChatFlow).findOneBy({ id: record.targetId }) - if (!chatflow) throw new Error(`ChatFlow ${record.targetId} not found`) - if (chatflow.type !== 'AGENTFLOW') throw new Error(`ChatFlow ${record.targetId} is not type AGENTFLOW`) + const ctx = { + appDataSource: appServer.AppDataSource, + componentNodes: appServer.nodesPool.componentNodes, + telemetry: appServer.telemetry, + cachePool: appServer.cachePool, + usageCacheManager: appServer.usageCacheManager, + sseStreamer: appServer.sseStreamer + } - const chatId = uuidv4() - const incomingInput: IncomingAgentflowInput = { - question: record.defaultInput || '', - chatId, - streaming: false + await executeScheduleJob(ctx, scheduleRecordId, { + onRecordNotFoundOrDisabled: () => { + this._removeCronJob(scheduleRecordId) + }, + onRecordExpiredOrInvalid: async (record) => { + record.enabled = false + await appServer.AppDataSource.getRepository(ScheduleRecord).save(record) + this._removeCronJob(record.id) } - - const result = await executeAgentFlow({ - componentNodes: appServer.nodesPool.componentNodes, - incomingInput, - chatflow, - chatId, - appDataSource, - telemetry: appServer.telemetry, - cachePool: appServer.cachePool, - usageCacheManager: appServer.usageCacheManager, - sseStreamer: appServer.sseStreamer, - baseURL: '', - isInternal: true, - uploadedFilesContent: '', - fileUploads: [], - isTool: true, // suppresses SSE streaming - workspaceId: chatflow.workspaceId ?? record.workspaceId, - orgId: '', - subscriptionId: '', - productId: '' - }) - - const elapsedTimeMs = Date.now() - startTime - const executionId: string | undefined = - result && typeof result === 'object' && 'executionId' in result ? (result as any).executionId : undefined - - await scheduleService.updateTriggerLog(appDataSource, log.id, { - status: ScheduleTriggerStatus.SUCCEEDED, - elapsedTimeMs, - executionId - }) - await scheduleService.updateLastRunAt(appDataSource, record.id, new Date()) - logger.debug(`[ScheduleBeat]: Fired schedule ${record.id} successfully (${elapsedTimeMs}ms)`) - } catch (error) { - const elapsedTimeMs = Date.now() - startTime - const errMsg = error instanceof Error ? error.message : String(error) - await scheduleService.updateTriggerLog(appDataSource, log.id, { - status: ScheduleTriggerStatus.FAILED, - elapsedTimeMs, - error: errMsg - }) - logger.error(`[ScheduleBeat]: Schedule ${record.id} failed: ${errMsg}`) - } + }) } } diff --git a/packages/server/src/queue/ScheduleExecutor.ts b/packages/server/src/queue/ScheduleExecutor.ts new file mode 100644 index 00000000000..7cf70377e16 --- /dev/null +++ b/packages/server/src/queue/ScheduleExecutor.ts @@ -0,0 +1,212 @@ +/** + * ScheduleExecutor + * + * Shared execution logic for scheduled agentflow jobs. Used by both + * ScheduleBeat (non-queue / node-cron mode) and ScheduleQueue (BullMQ mode) + * so that validation, execution, logging, and post-run updates live in one place. + */ + +import { DataSource } from 'typeorm' +import { IComponentNodes, IncomingAgentflowInput } from '../Interface' +import { IServerSideEventStreamer } from 'flowise-components' +import { ScheduleRecord, ScheduleTriggerType } from '../database/entities/ScheduleRecord' +import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' +import { ChatFlow } from '../database/entities/ChatFlow' +import { executeAgentFlow } from '../utils/buildAgentflow' +import scheduleService from '../services/schedule' +import { Telemetry } from '../utils/telemetry' +import { CachePool } from '../CachePool' +import { UsageCacheManager } from '../UsageCacheManager' +import { v4 as uuidv4 } from 'uuid' +import logger from '../utils/logger' + +// ─── Types ───────────────────────────────────────────────────────────────────── + +/** + * Runtime dependencies required to execute a scheduled agentflow. + * Both queue and non-queue modes supply these from their own context. + */ +export interface ScheduleExecutionContext { + appDataSource: DataSource + componentNodes: IComponentNodes + telemetry: Telemetry + cachePool: CachePool + usageCacheManager: UsageCacheManager + sseStreamer: IServerSideEventStreamer +} + +/** + * Optional hooks for mode-specific side-effects during validation. + * These let each mode handle cleanup its own way (e.g. removing a cron job + * vs. removing a BullMQ job scheduler) without polluting the shared logic. + */ +export interface ScheduleExecutionCallbacks { + /** Called when the schedule record is not found or is disabled. */ + onRecordNotFoundOrDisabled?: (scheduleRecordId: string) => Promise | void + /** Called when the schedule has passed its endDate or has invalid input. */ + onRecordExpiredOrInvalid?: (record: ScheduleRecord) => Promise | void +} + +// ─── Public API ──────────────────────────────────────────────────────────────── + +/** + * Validate and execute a single scheduled agentflow job. + * + * Pipeline: + * 1. Load ScheduleRecord from DB + * 2. Check enabled / endDate / defaultInput / nextRunAt β†’ SKIPPED if invalid + * 3. Create RUNNING trigger log + * 4. Load ChatFlow, build input, execute agentflow + * 5. Update trigger log (SUCCEEDED / FAILED) + * 6. Update schedule after run (lastRunAt, nextRunAt) + * + * @returns The agentflow execution result, or `undefined` if skipped. + */ +export async function executeScheduleJob( + ctx: ScheduleExecutionContext, + scheduleRecordId: string, + callbacks?: ScheduleExecutionCallbacks +): Promise { + const scheduledAt = new Date() + const { appDataSource } = ctx + + // ── 1. Load & validate record ────────────────────────────────────────── + const scheduleRecord = await appDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId }) + + // If the record is missing entirely, log and skip without creating a trigger log. + if (!scheduleRecord) { + logger.warn(`[ScheduleExecutor]: Schedule ${scheduleRecordId} not found, skipping`) + await callbacks?.onRecordNotFoundOrDisabled?.(scheduleRecordId) + return undefined + } + // If the record exists but is disabled, record a SKIPPED trigger log with proper attribution. + if (!scheduleRecord.enabled) { + logger.warn(`[ScheduleExecutor]: Schedule ${scheduleRecordId} disabled, skipping`) + await callbacks?.onRecordNotFoundOrDisabled?.(scheduleRecordId) + await scheduleService.createTriggerLog({ + appDataSource, + scheduleRecordId, + triggerType: scheduleRecord.triggerType ?? ScheduleTriggerType.AGENTFLOW, + targetId: scheduleRecord.targetId, + status: ScheduleTriggerStatus.SKIPPED, + scheduledAt, + workspaceId: scheduleRecord.workspaceId + }) + return undefined + } + + // ── 2. End-date / input validation ───────────────────────────────────── + const isDefaultInputValid = scheduleService.isDefaultInputValid(scheduleRecord.defaultInput) + if ((scheduleRecord.endDate && scheduledAt >= scheduleRecord.endDate) || !isDefaultInputValid) { + logger.debug(`[ScheduleExecutor]: Schedule ${scheduleRecordId} has passed end date or invalid input, disabling`) + await callbacks?.onRecordExpiredOrInvalid?.(scheduleRecord) + await scheduleService.createTriggerLog({ + appDataSource, + scheduleRecordId, + triggerType: scheduleRecord.triggerType ?? ScheduleTriggerType.AGENTFLOW, + targetId: scheduleRecord.targetId, + status: ScheduleTriggerStatus.SKIPPED, + scheduledAt, + workspaceId: scheduleRecord.workspaceId + }) + return undefined + } + + // ── 3. nextRunAt guard ───────────────────────────────────────────────── + if (scheduleRecord.nextRunAt && scheduleRecord.nextRunAt > scheduledAt) { + logger.debug( + `[ScheduleExecutor]: Scheduled time ${scheduledAt.toISOString()} is before nextRunAt ` + + `${scheduleRecord.nextRunAt.toISOString()} for schedule ${scheduleRecordId}, skipping` + ) + await scheduleService.createTriggerLog({ + appDataSource, + scheduleRecordId, + triggerType: scheduleRecord.triggerType ?? ScheduleTriggerType.AGENTFLOW, + targetId: scheduleRecord.targetId, + status: ScheduleTriggerStatus.SKIPPED, + scheduledAt, + workspaceId: scheduleRecord.workspaceId + }) + return undefined + } + + // ── 4. Execute ───────────────────────────────────────────────────────── + return _executeAgentflow(ctx, scheduleRecord, scheduledAt) +} + +// ─── Internal ────────────────────────────────────────────────────────────────── + +async function _executeAgentflow(ctx: ScheduleExecutionContext, record: ScheduleRecord, scheduledAt: Date): Promise { + const { appDataSource, componentNodes, telemetry, cachePool, usageCacheManager, sseStreamer } = ctx + const startTime = Date.now() + + const log = await scheduleService.createTriggerLog({ + appDataSource, + scheduleRecordId: record.id, + triggerType: record.triggerType, + targetId: record.targetId, + status: ScheduleTriggerStatus.RUNNING, + scheduledAt, + workspaceId: record.workspaceId + }) + + try { + const chatflow = await appDataSource.getRepository(ChatFlow).findOneBy({ id: record.targetId }) + if (!chatflow) throw new Error(`ChatFlow ${record.targetId} not found`) + if (chatflow.type !== 'AGENTFLOW') throw new Error(`ChatFlow ${record.targetId} is not of type AGENTFLOW`) + + const chatId = uuidv4() + const incomingInput: IncomingAgentflowInput = { + question: record.defaultInput || '', + chatId, + streaming: false + } + + const result = await executeAgentFlow({ + componentNodes, + incomingInput, + chatflow, + chatId, + appDataSource, + telemetry, + cachePool, + usageCacheManager, + sseStreamer, + baseURL: '', + isInternal: true, + uploadedFilesContent: '', + fileUploads: [], + isTool: true, + workspaceId: chatflow.workspaceId ?? record.workspaceId, + orgId: '', + subscriptionId: '', + productId: '' + }) + + const elapsedTimeMs = Date.now() - startTime + const executionId: string | undefined = + result && typeof result === 'object' && 'executionId' in result ? (result as any).executionId : undefined + + await scheduleService.updateTriggerLog(appDataSource, log.id, { + status: ScheduleTriggerStatus.SUCCEEDED, + elapsedTimeMs, + executionId + }) + + await scheduleService.updateScheduleAfterRun(appDataSource, record.id, record.cronExpression, record.timezone ?? 'UTC') + logger.debug(`[ScheduleExecutor]: Completed schedule ${record.id} (${elapsedTimeMs}ms)`) + return result + } catch (error) { + const elapsedTimeMs = Date.now() - startTime + const errMsg = error instanceof Error ? error.message : String(error) + + await scheduleService.updateTriggerLog(appDataSource, log.id, { + status: ScheduleTriggerStatus.FAILED, + elapsedTimeMs, + error: errMsg + }) + + logger.error(`[ScheduleExecutor]: Schedule ${record.id} failed: ${errMsg}`) + throw error + } +} diff --git a/packages/server/src/queue/ScheduleQueue.ts b/packages/server/src/queue/ScheduleQueue.ts index 8f8b645de37..73ebeb86ca5 100644 --- a/packages/server/src/queue/ScheduleQueue.ts +++ b/packages/server/src/queue/ScheduleQueue.ts @@ -1,12 +1,7 @@ import { RedisOptions, RepeatOptions } from 'bullmq' import { BaseQueue } from './BaseQueue' -import { executeAgentFlow } from '../utils/buildAgentflow' -import { ScheduleRecord, ScheduleTriggerType } from '../database/entities/ScheduleRecord' -import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' -import scheduleService from '../services/schedule' -import { IComponentNodes, IncomingAgentflowInput } from '../Interface' -import { ChatFlow } from '../database/entities/ChatFlow' -import { v4 as uuidv4 } from 'uuid' +import { ScheduleRecord } from '../database/entities/ScheduleRecord' +import { IComponentNodes } from '../Interface' import logger from '../utils/logger' import { IScheduleAgentflowJobData } from '../Interface.Schedule' import { DataSource } from 'typeorm' @@ -14,6 +9,7 @@ import { Telemetry } from '../utils/telemetry' import { CachePool } from '../CachePool' import { UsageCacheManager } from '../UsageCacheManager' import { RedisEventPublisher } from './RedisEventPublisher' +import { executeScheduleJob } from './ScheduleExecutor' interface ScheduleQueueOptions { appDataSource: DataSource @@ -69,98 +65,27 @@ export class ScheduleQueue extends BaseQueue { if (this.usageCacheManager) data.usageCacheManager = this.usageCacheManager if (this.componentNodes) data.componentNodes = this.componentNodes - const { scheduleRecordId, targetId, defaultInput, workspaceId } = data - // Compute the effective scheduled time at execution to avoid reusing - // a stale timestamp baked into the repeatable job payload. - const scheduledAtDate = new Date() - const startTime = Date.now() + const { scheduleRecordId } = data - const scheduleRecord = await this.appDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId }) - if (!scheduleRecord) { - logger.error(`[ScheduleQueue]: Schedule record ${scheduleRecordId} not found, skipping job`) - return - } - if (!scheduleRecord.enabled) { - logger.debug(`[ScheduleQueue]: Schedule record ${scheduleRecordId} is disabled, skipping job`) - return - } - - // Create an initial log entry - const log = await scheduleService.createTriggerLog({ + const ctx = { appDataSource: this.appDataSource, - scheduleRecordId, - triggerType: scheduleRecord.triggerType ?? ScheduleTriggerType.AGENTFLOW, - targetId, - status: ScheduleTriggerStatus.RUNNING, - scheduledAt: scheduledAtDate, - workspaceId - }) - - try { - // Load the chatflow - const chatflow = await this.appDataSource.getRepository(ChatFlow).findOneBy({ id: targetId }) - if (!chatflow) { - throw new Error(`ChatFlow ${targetId} not found`) - } - if (chatflow.type !== 'AGENTFLOW') { - throw new Error(`ChatFlow ${targetId} is not of type AGENTFLOW`) - } + componentNodes: this.componentNodes, + telemetry: this.telemetry, + cachePool: this.cachePool, + usageCacheManager: this.usageCacheManager, + sseStreamer: this.redisPublisher + } - // Build minimal IncomingAgentflowInput - const chatId = uuidv4() - const incomingInput: IncomingAgentflowInput = { - question: defaultInput || '', - chatId, - streaming: false + return executeScheduleJob(ctx, scheduleRecordId, { + onRecordNotFoundOrDisabled: async () => { + await this.removeJobScheduler(scheduleRecordId) + }, + onRecordExpiredOrInvalid: async (record) => { + record.enabled = false + await this.appDataSource.getRepository(ScheduleRecord).save(record) + await this.removeJobScheduler(scheduleRecordId) } - - const result = await executeAgentFlow({ - componentNodes: this.componentNodes, - incomingInput, - chatflow, - chatId, - appDataSource: this.appDataSource, - telemetry: this.telemetry, - cachePool: this.cachePool, - usageCacheManager: this.usageCacheManager, - sseStreamer: this.redisPublisher, - baseURL: '', - isInternal: true, - uploadedFilesContent: '', - fileUploads: [], - isTool: true, // suppresses SSE streaming - workspaceId: chatflow.workspaceId ?? workspaceId, - orgId: '', - subscriptionId: '', - productId: '' - }) - - const elapsedTimeMs = Date.now() - startTime - const executionId: string | undefined = - result && typeof result === 'object' && 'executionId' in result ? (result as any).executionId : undefined - - await scheduleService.updateTriggerLog(this.appDataSource, log.id, { - status: ScheduleTriggerStatus.SUCCEEDED, - elapsedTimeMs, - executionId - }) - - await scheduleService.updateLastRunAt(this.appDataSource, scheduleRecordId, new Date()) - logger.debug(`[ScheduleQueue]: Completed job for schedule ${scheduleRecordId} (${elapsedTimeMs}ms)`) - return result - } catch (error) { - const elapsedTimeMs = Date.now() - startTime - const errMsg = error instanceof Error ? error.message : String(error) - logger.error(`[ScheduleQueue]: Job failed for schedule ${scheduleRecordId}: ${errMsg}`) - - await scheduleService.updateTriggerLog(this.appDataSource, log.id, { - status: ScheduleTriggerStatus.FAILED, - elapsedTimeMs, - error: errMsg - }) - - throw error - } + }) } /** @@ -188,7 +113,7 @@ export class ScheduleQueue extends BaseQueue { data: jobData }) - logger.info(`[ScheduleQueue]: Registered repeatable job for schedule ${record.id} (${record.cronExpression})`) + logger.debug(`[ScheduleQueue]: Registered repeatable job for schedule ${record.id} (${record.cronExpression})`) } /** @@ -197,7 +122,7 @@ export class ScheduleQueue extends BaseQueue { public async removeJobScheduler(scheduleRecordId: string): Promise { try { await this.queue.removeJobScheduler(`schedule:${scheduleRecordId}`) - logger.info(`[ScheduleQueue]: Removed repeatable job for schedule ${scheduleRecordId}`) + logger.debug(`[ScheduleQueue]: Removed repeatable job for schedule ${scheduleRecordId}`) } catch (error) { logger.warn(`[ScheduleQueue]: Could not remove repeatable job for schedule ${scheduleRecordId}: ${error}`) } diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 5d2ec2609ec..200b67ac8d2 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -40,4 +40,12 @@ router.get( chatflowsController.checkIfChatflowHasChanged ) +// SCHEDULE +router.get( + '/:id/schedule/status', + checkAnyPermission('chatflows:view,chatflows:update,agentflows:view,agentflows:update'), + chatflowsController.getScheduleStatus +) +router.patch('/:id/schedule/enabled', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.toggleScheduleEnabled) + export default router diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 1c4adcae0f4..0595acbf3ba 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -23,7 +23,7 @@ import { utilGetUploadsConfig } from '../../utils/getUploadsConfig' import logger from '../../utils/logger' import { updateStorageUsage } from '../../utils/quotaUsage' import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' -import scheduleService, { resolveScheduleCron } from '../../services/schedule' +import scheduleService from '../../services/schedule' import { ScheduleBeat } from '../../queue/ScheduleBeat' export const enum ChatflowErrorMessage { @@ -131,7 +131,7 @@ const deleteChatflow = async (chatflowId: string, orgId: string, workspaceId: st // delete schedules related to the chatflow if it's an agentflow if (chatflow.type === EnumChatflowType.AGENTFLOW) { - const existingRecord = await scheduleService.disableSchedulesForTarget(chatflow.id, ScheduleTriggerType.AGENTFLOW, workspaceId) + const existingRecord = await scheduleService.deleteScheduleForTarget(chatflow.id, ScheduleTriggerType.AGENTFLOW, workspaceId) if (existingRecord) { await ScheduleBeat.getInstance().onScheduleChanged(existingRecord.id, 'delete') } @@ -328,21 +328,23 @@ const saveChatflow = async ( const startNode = nodes.find((node) => node.data.name === 'startAgentflow') const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' | 'scheduleInput' if (startInputType === 'scheduleInput') { - const cronResult = resolveScheduleCron(startNode?.data?.inputs ?? {}) + const resolvedCron = scheduleService.resolveScheduleCron(startNode?.data?.inputs || {}) const scheduleTimezone = startNode?.data?.inputs?.scheduleTimezone || 'UTC' const scheduleDefaultInput = startNode?.data?.inputs?.scheduleDefaultInput || '' - if (cronResult.valid) { - const record = await scheduleService.createOrUpdateSchedule({ - triggerType: ScheduleTriggerType.AGENTFLOW, - targetId: dbResponse.id, - nodeId: startNode?.id, - cronExpression: cronResult.cronExpression!, - timezone: scheduleTimezone, - enabled: true, - defaultInput: scheduleDefaultInput, - workspaceId - }) - + const scheduleEndDate = startNode?.data?.inputs?.scheduleEndDate ? new Date(startNode.data.inputs.scheduleEndDate) : undefined + const enabled = scheduleService.canScheduleEnable(startNode?.data?.inputs ?? {}) + const record = await scheduleService.createOrUpdateSchedule({ + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: dbResponse.id, + nodeId: startNode?.id, + cronExpression: resolvedCron.cronExpression || '', + timezone: scheduleTimezone, + enabled: enabled, + defaultInput: scheduleDefaultInput, + workspaceId, + endDate: scheduleEndDate + }) + if (enabled) { // Notify the beat to sync the schedule await ScheduleBeat.getInstance().onScheduleChanged(record.id, 'upsert') } @@ -414,7 +416,6 @@ const updateChatflow = async ( const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).save(newDbChatflow) // Check if the flow is agentflow and if it has a schedule node, if yes then notify the beat to sync the schedule - let shouldDisableSchedule = false if (dbResponse.type === EnumChatflowType.AGENTFLOW) { const flowData = dbResponse.flowData const parsedFlowData: IReactFlowObject = JSON.parse(flowData) @@ -422,39 +423,32 @@ const updateChatflow = async ( const startNode = nodes.find((node) => node.data.name === 'startAgentflow') const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' | 'scheduleInput' if (startInputType === 'scheduleInput') { - const cronResult = resolveScheduleCron(startNode?.data?.inputs ?? {}) + const resolvedCron = scheduleService.resolveScheduleCron(startNode?.data?.inputs || {}) const scheduleTimezone = startNode?.data?.inputs?.scheduleTimezone || 'UTC' const scheduleDefaultInput = startNode?.data?.inputs?.scheduleDefaultInput || '' - if (cronResult.valid) { - const record = await scheduleService.createOrUpdateSchedule({ - triggerType: ScheduleTriggerType.AGENTFLOW, - targetId: dbResponse.id, - nodeId: startNode?.id, - cronExpression: cronResult.cronExpression!, - timezone: scheduleTimezone, - enabled: true, - defaultInput: scheduleDefaultInput, - workspaceId - }) - - // Notify the beat to sync the schedule + const scheduleEndDate = startNode?.data?.inputs?.scheduleEndDate ? new Date(startNode.data.inputs.scheduleEndDate) : undefined + const canEnable = scheduleService.canScheduleEnable(startNode?.data?.inputs ?? {}) + const record = await scheduleService.createOrUpdateSchedule({ + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: dbResponse.id, + nodeId: startNode?.id, + cronExpression: resolvedCron.cronExpression || '', + timezone: scheduleTimezone, + enabled: canEnable === false ? false : undefined, // automatically disable schedule if it cannot be enabled; otherwise preserve the existing enabled value + defaultInput: scheduleDefaultInput, + workspaceId, + endDate: scheduleEndDate + }) + if (record.enabled) { + // Notify the beat to sync the (enabled) schedule await ScheduleBeat.getInstance().onScheduleChanged(record.id, 'upsert') } else { - // If the schedule configuration is invalid, we should disable the existing schedule if it exists, - // to prevent potential issues caused by invalid schedule configuration - shouldDisableSchedule = true + // Schedule is disabled; ensure any existing scheduled job is removed + await ScheduleBeat.getInstance().onScheduleChanged(record.id, 'delete') } } else { - // If the start node is not scheduleInput, then we need to disable the existing schedule if it exists - shouldDisableSchedule = true - } - - if (shouldDisableSchedule) { - const existingRecord = await scheduleService.disableSchedulesForTarget( - dbResponse.id, - ScheduleTriggerType.AGENTFLOW, - workspaceId - ) + // If the start node is not scheduleInput, then we need to delete the existing schedule if it exists + const existingRecord = await scheduleService.deleteScheduleForTarget(dbResponse.id, ScheduleTriggerType.AGENTFLOW, workspaceId) if (existingRecord) { await ScheduleBeat.getInstance().onScheduleChanged(existingRecord.id, 'delete') } diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts index c3347917f61..a3e14d44b58 100644 --- a/packages/server/src/services/schedule/index.ts +++ b/packages/server/src/services/schedule/index.ts @@ -2,6 +2,7 @@ import { StatusCodes } from 'http-status-codes' import { v4 as uuidv4 } from 'uuid' import { ScheduleRecord, ScheduleTriggerType } from '../../database/entities/ScheduleRecord' import { ScheduleTriggerLog, ScheduleTriggerStatus } from '../../database/entities/ScheduleTriggerLog' +import { ChatFlow } from '../../database/entities/ChatFlow' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' @@ -16,6 +17,7 @@ export interface CreateScheduleInput { timezone?: string enabled?: boolean defaultInput?: string + endDate?: Date workspaceId: string } @@ -24,8 +26,21 @@ export interface UpdateScheduleInput { timezone?: string enabled?: boolean defaultInput?: string + endDate?: Date | null } +/** + * A fallback cron expression used when the provided one is invalid, + * to prevent the schedule from being deleted and to allow users + * to fix the cron expression without losing the schedule record. + * The beat will skip execution if it detects this fallback expression, and will log an error for visibility. + */ +const FALLBACK_CRON_EXPRESSION = '0 0 * * *' // daily at midnight UTC +const FALLBACK_TIMEZONE = 'UTC' + +/* Schedule batch size for processing schedules in batches */ +const SCHEDULE_BATCH_SIZE = 100 + /** * Validates a cron expression and returns parsed info. * Uses a lightweight regex-based check without external dependencies. @@ -117,10 +132,9 @@ const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise => { +const getEnabledSchedulesBatch = async (skip: number = 0, take: number = SCHEDULE_BATCH_SIZE): Promise => { try { const appServer = getRunningExpressApp() return await appServer.AppDataSource.getRepository(ScheduleRecord).find({ - where: { enabled: true } + where: { enabled: true }, + order: { createdDate: 'ASC' }, + skip, + take }) } catch (error) { throw new InternalFlowiseError( StatusCodes.INTERNAL_SERVER_ERROR, - `Error: scheduleService.getAllEnabledSchedules - ${getErrorMessage(error)}` + `Error: scheduleService.getEnabledSchedulesBatch - ${getErrorMessage(error)}` ) } } -const updateLastRunAt = async (appDataSource: DataSource, scheduleRecordId: string, lastRunAt: Date): Promise => { +// --------------------------------------------------------------------------- +// Cron field helpers (used by computeNextRunAt) +// --------------------------------------------------------------------------- +function _matchCronField(field: string, value: number, min: number): boolean { + if (field === '*') return true + for (const part of field.split(',')) { + if (part.includes('/')) { + const [rangeStr, stepStr] = part.split('/') + const step = parseInt(stepStr, 10) + if (isNaN(step)) continue + if (rangeStr === '*') { + if ((value - min) % step === 0) return true + } else if (rangeStr.includes('-')) { + const [start, end] = rangeStr.split('-').map(Number) + if (value >= start && value <= end && (value - start) % step === 0) return true + } else { + if (value === parseInt(rangeStr, 10)) return true + } + } else if (part.includes('-')) { + const [start, end] = part.split('-').map(Number) + if (value >= start && value <= end) return true + } else { + if (value === parseInt(part, 10)) return true + } + } + return false +} + +interface _ParsedCronFields { + minuteField: string + hourField: string + domField: string + monthField: string + dowField: string +} + +/** Parse a cron expression once so fields can be reused across many date checks. */ +function _parseCronFields(expression: string): _ParsedCronFields { + const fields = expression.trim().split(/\s+/) + const offset = fields.length === 6 ? 1 : 0 + return { + minuteField: fields[0 + offset], + hourField: fields[1 + offset], + domField: fields[2 + offset], + monthField: fields[3 + offset], + dowField: fields[4 + offset] + } +} + +/** + * Check whether a pre-parsed cron matches `date`, using a pre-built Intl.DateTimeFormat for TZ conversion. + * Both `parsed` and `fmt` should be created once outside any hot loop. + */ +function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.DateTimeFormat): boolean { + let minute: number, hour: number, dom: number, month: number, dow: number try { - await appDataSource.getRepository(ScheduleRecord).update({ id: scheduleRecordId }, { lastRunAt }) + const parts = fmt.formatToParts(date) + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) + const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun' + minute = get('minute') + hour = get('hour') % 24 + dom = get('day') + month = get('month') + dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayStr) + if (dow === -1) dow = date.getUTCDay() + } catch { + minute = date.getUTCMinutes() + hour = date.getUTCHours() + dom = date.getUTCDate() + month = date.getUTCMonth() + 1 + dow = date.getUTCDay() + } + const dowMatches = _matchCronField(parsed.dowField, dow, 0) || (dow === 0 && _matchCronField(parsed.dowField, 7, 0)) + return ( + _matchCronField(parsed.minuteField, minute, 0) && + _matchCronField(parsed.hourField, hour, 0) && + _matchCronField(parsed.domField, dom, 1) && + _matchCronField(parsed.monthField, month, 1) && + dowMatches + ) +} + +/** + * Computes the next Date after `after` (defaults to now) when the cron expression will fire. + * Searches minute-by-minute, up to 1 year ahead. Returns null if no match is found. + * + * The Intl.DateTimeFormat instance and parsed cron fields are created once before the loop + * to avoid repeated allocations on every iteration. + */ +export const computeNextRunAt = (cronExpression: string, timezone: string = 'UTC', after?: Date): Date | null => { + const start = new Date(after ? after.getTime() : Date.now()) + // Snap to start of next minute so we never return the current minute + start.setSeconds(0, 0) + start.setMinutes(start.getMinutes() + 1) + + // Hoist allocations outside the loop + const parsed = _parseCronFields(cronExpression) + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + weekday: 'short', + hour12: false + }) + + const maxIterations = 60 * 24 * 366 // up to ~1 year of minutes + for (let i = 0; i < maxIterations; i++) { + const candidate = new Date(start.getTime() + i * 60_000) + if (_cronMatchesParsed(parsed, candidate, fmt)) { + return candidate + } + } + return null +} + +const updateScheduleAfterRun = async ( + appDataSource: DataSource, + scheduleRecordId: string, + cronExpression: string, + timezone: string = 'UTC' +): Promise => { + try { + const lastRunAt = new Date() + const nextRunAt = computeNextRunAt(cronExpression, timezone, lastRunAt) ?? undefined + await appDataSource.getRepository(ScheduleRecord).update({ id: scheduleRecordId }, { lastRunAt, nextRunAt }) } catch (error) { - logger.error(`[ScheduleService]: updateLastRunAt failed for ${scheduleRecordId}: ${getErrorMessage(error)}`) + logger.error(`[ScheduleService]: updateScheduleAfterRun failed for ${scheduleRecordId}: ${getErrorMessage(error)}`) + } +} + +/** + * Returns the current schedule record and whether it can be enabled, + * validated against the live flowData (not the stored cron which may be a fallback). + */ +const getScheduleStatus = async ( + targetId: string, + workspaceId: string +): Promise<{ record: ScheduleRecord | null; canEnable: boolean; reason?: string }> => { + try { + const appServer = getRunningExpressApp() + const record = await appServer.AppDataSource.getRepository(ScheduleRecord).findOne({ + where: { targetId, triggerType: ScheduleTriggerType.AGENTFLOW, workspaceId } + }) + + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: targetId, workspaceId } + }) + if (!chatflow?.flowData) { + return { record, canEnable: false, reason: 'Flow not found or has no data' } + } + + try { + const parsedFlowData = JSON.parse(chatflow.flowData) + const startNode = (parsedFlowData.nodes || []).find((n: any) => n.data?.name === 'startAgentflow') + if (!startNode || startNode.data?.inputs?.startInputType !== 'scheduleInput') { + return { record, canEnable: false, reason: 'Flow is not configured as a scheduled flow' } + } + + const inputs = startNode.data.inputs as Record + const cronResult = resolveScheduleCron(inputs) + if (!cronResult.valid) { + return { record, canEnable: false, reason: cronResult.error || 'Invalid cron expression or timezone' } + } + + // endDate must be in the future if set + const endDateValue = inputs.scheduleEndDate || record?.endDate + if (endDateValue) { + const endDate = new Date(endDateValue) + if (isNaN(endDate.getTime())) { + return { record, canEnable: false, reason: 'Invalid end date' } + } + if (endDate <= new Date()) { + return { record, canEnable: false, reason: 'End date is in the past' } + } + } + + // defaultInput is required for cron-based schedules since there is no user to provide a question at runtime + const isDefaultInputValidResult = isDefaultInputValid(inputs.scheduleDefaultInput ?? record?.defaultInput) + if (!isDefaultInputValidResult) { + return { record, canEnable: false, reason: 'Default input is required to enable schedule' } + } + + return { record, canEnable: true } + } catch { + return { record, canEnable: false, reason: 'Could not parse flow data' } + } + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.getScheduleStatus - ${getErrorMessage(error)}` + ) + } +} + +/** + * Toggles the enabled state of a schedule record. + * When enabling, validates the schedule config first. + * Caller is responsible for notifying ScheduleBeat after this returns. + */ +const toggleScheduleEnabled = async (targetId: string, workspaceId: string, enabled: boolean): Promise => { + try { + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ScheduleRecord) + const record = await repo.findOne({ + where: { targetId, triggerType: ScheduleTriggerType.AGENTFLOW, workspaceId } + }) + if (!record) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'No schedule record found for this flow') + } + + if (enabled) { + const status = await getScheduleStatus(targetId, workspaceId) + if (!status.canEnable) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, status.reason || 'Cannot enable schedule: invalid configuration') + } + } + + record.enabled = enabled + const saved = await repo.save(record) + logger.debug(`[ScheduleService]: Schedule ${record.id} toggled to ${enabled ? 'enabled' : 'disabled'}`) + return saved + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.toggleScheduleEnabled - ${getErrorMessage(error)}` + ) } } @@ -398,15 +647,39 @@ export const resolveScheduleCron = (inputs: Record): { valid: boole return { valid: true, cronExpression: expression } } +/** + * Checks if the schedule can be enabled based on its inputs. + * It is used to determine the initial enabled state when creating/updating a schedule, and also to validate when toggling enabled state. + * Besides, the worker skips execution of schedules that are not valid. + */ +export const isDefaultInputValid = (defaultInput: string | undefined): boolean => { + return !!defaultInput && defaultInput !== '

' // rich text empty value +} + +/** + * Determines if a schedule can be enabled based on its inputs, including the cron expression, end date, and default input. + */ +export const canScheduleEnable = (inputs: Record): boolean => { + const cronResult = resolveScheduleCron(inputs) + const isEndDateValid = !inputs.scheduleEndDate || new Date(inputs.scheduleEndDate) > new Date() + const isInputValid = isDefaultInputValid(inputs.scheduleDefaultInput) + return cronResult.valid && isEndDateValid && isInputValid +} + export default { validateCronExpression, validateVisualPickerFields, buildCronFromVisualPicker, resolveScheduleCron, createOrUpdateSchedule, - disableSchedulesForTarget, - getAllEnabledSchedules, - updateLastRunAt, + deleteScheduleForTarget, + getEnabledSchedulesBatch, + updateScheduleAfterRun, + computeNextRunAt, createTriggerLog, - updateTriggerLog + updateTriggerLog, + getScheduleStatus, + toggleScheduleEnabled, + isDefaultInputValid, + canScheduleEnable } diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index a5d4f323ac5..14d224f20f1 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -22,6 +22,10 @@ const getHasChatflowChanged = (id, lastUpdatedDateTime) => client.get(`/chatflow const generateAgentflow = (body) => client.post(`/agentflowv2-generator/generate`, body) +const getScheduleStatus = (id) => client.get(`/chatflows/${id}/schedule/status`) + +const toggleScheduleEnabled = (id, enabled) => client.patch(`/chatflows/${id}/schedule/enabled`, { enabled }) + export default { getAllChatflows, getAllAgentflows, @@ -33,5 +37,7 @@ export default { getIsChatflowStreaming, getAllowChatflowUploads, getHasChatflowChanged, - generateAgentflow + generateAgentflow, + getScheduleStatus, + toggleScheduleEnabled } diff --git a/packages/ui/src/ui-component/picker/DatePicker.jsx b/packages/ui/src/ui-component/picker/DatePicker.jsx new file mode 100644 index 00000000000..9d549978f21 --- /dev/null +++ b/packages/ui/src/ui-component/picker/DatePicker.jsx @@ -0,0 +1,58 @@ +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { Box, TextField } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +export const DatePicker = ({ value, onChange, disabled = false, placeholder = 'YYYY-MM-DD' }) => { + const theme = useTheme() + + // Normalise to "YYYY-MM-DD" for the native date input + const toDateString = (val) => { + if (!val) return '' + const d = new Date(val) + if (isNaN(d.getTime())) return '' + return d.toISOString().slice(0, 10) + } + + const [dateValue, setDateValue] = useState(toDateString(value)) + + useEffect(() => { + setDateValue(toDateString(value)) + }, [value]) + + const handleChange = (e) => { + const newValue = e.target.value // "YYYY-MM-DD" or "" + setDateValue(newValue) + // Propagate as ISO string (end-of-day UTC) so backend can parse it as a Date + onChange(newValue ? new Date(newValue).toISOString() : '') + } + + return ( + + + + ) +} + +DatePicker.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + placeholder: PropTypes.string +} diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index a6d0bc3ab12..08297ce2295 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -140,7 +140,8 @@ export const initNode = (nodeData, newNodeId, isAgentflow) => { 'conditionFunction', // This is a special type for condition functions 'timePicker', 'weekDaysPicker', - 'monthDaysPicker' + 'monthDaysPicker', + 'datePicker' ] // Inputs diff --git a/packages/ui/src/views/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx index 85dccf45134..12020ef059d 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.jsx +++ b/packages/ui/src/views/canvas/CanvasHeader.jsx @@ -1,11 +1,11 @@ import PropTypes from 'prop-types' import { useNavigate } from 'react-router-dom' import { useSelector, useDispatch } from 'react-redux' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' // material-ui import { useTheme } from '@mui/material/styles' -import { Avatar, Box, ButtonBase, Typography, Stack, TextField, Button } from '@mui/material' +import { Avatar, Box, ButtonBase, Typography, Stack, TextField, Button, Tooltip } from '@mui/material' // icons import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconCode } from '@tabler/icons-react' @@ -66,8 +66,25 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, const title = isAgentCanvas ? 'Agents' : 'Chatflow' const updateChatflowApi = useApi(chatflowsApi.updateChatflow) + const getScheduleStatusApi = useApi(chatflowsApi.getScheduleStatus) + const toggleScheduleEnabledApi = useApi(chatflowsApi.toggleScheduleEnabled) const canvas = useSelector((state) => state.canvas) + const [scheduleEnabled, setScheduleEnabled] = useState(false) + const [scheduleCanEnable, setScheduleCanEnable] = useState(false) + const [scheduleCanEnableReason, setScheduleCanEnableReason] = useState('') + + const isScheduleFlow = useMemo(() => { + if (!chatflow?.flowData || !isAgentflowV2) return false + try { + const parsed = JSON.parse(chatflow.flowData) + const startNode = (parsed.nodes || []).find((n) => n.data?.name === 'startAgentflow') + return startNode?.data?.inputs?.startInputType === 'scheduleInput' + } catch { + return false + } + }, [chatflow?.flowData, isAgentflowV2]) + const onSettingsItemClick = (setting) => { setSettingsOpen(false) @@ -248,6 +265,47 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, } }, [chatflow, title, chatflowConfigurationDialogOpen]) + useEffect(() => { + if (chatflow?.id && isScheduleFlow) { + getScheduleStatusApi.request(chatflow.id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatflow?.id, chatflow?.updatedDate, isScheduleFlow]) + + useEffect(() => { + if (getScheduleStatusApi.data) { + setScheduleEnabled(getScheduleStatusApi.data.enabled ?? false) + setScheduleCanEnable(getScheduleStatusApi.data.canEnable ?? false) + setScheduleCanEnableReason(getScheduleStatusApi.data.reason || '') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getScheduleStatusApi.data]) + + useEffect(() => { + if (toggleScheduleEnabledApi.data) { + setScheduleEnabled(toggleScheduleEnabledApi.data.enabled ?? false) + enqueueSnackbar({ + message: `Schedule ${toggleScheduleEnabledApi.data.enabled ? 'enabled' : 'disabled'} successfully`, + options: { variant: 'success' } + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toggleScheduleEnabledApi.data]) + + useEffect(() => { + if (toggleScheduleEnabledApi.error) { + enqueueSnackbar({ + message: String(toggleScheduleEnabledApi.error?.message || toggleScheduleEnabledApi.error || 'Failed to toggle schedule'), + options: { variant: 'error' } + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toggleScheduleEnabledApi.error]) + + const handleToggleSchedule = (newEnabled) => { + toggleScheduleEnabledApi.request(chatflow.id, newEnabled) + } + return ( <> @@ -388,6 +446,72 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, + {chatflow?.id && isAgentflowV2 && isScheduleFlow && ( + + + + Schedule + + { + if (!scheduleCanEnable && !scheduleEnabled) return + handleToggleSchedule(!scheduleEnabled) + }} + sx={{ + position: 'relative', + display: 'inline-block', + width: 56, + height: 32, + borderRadius: '5px', + backgroundColor: scheduleEnabled ? theme.palette.success.main : theme.palette.grey[400], + transition: 'background-color 0.2s ease-in-out', + cursor: !scheduleCanEnable && !scheduleEnabled ? 'not-allowed' : 'pointer', + flexShrink: 0 + }} + > + + + + + )} {chatflow?.id && ( handleDataChange({ inputParam, newValue })} /> )} + {inputParam.type === 'datePicker' && ( + handleDataChange({ inputParam, newValue })} + /> + )} {inputParam.type === 'array' && } {/* CUSTOM INPUT LOGIC */} {inputParam.type.includes('conditionFunction') && ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3106ce087c..d862fd42430 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -975,6 +975,9 @@ importers: nanoid: specifier: '3' version: 3.3.7 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 nodemailer: specifier: ^7.0.7 version: 7.0.11 @@ -15769,6 +15772,10 @@ packages: node-cleanup@2.1.2: resolution: {integrity: sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -40185,6 +40192,8 @@ snapshots: node-cleanup@2.1.2: {} + node-cron@4.2.1: {} + node-domexception@1.0.0: {} node-ensure@0.0.0: {} From d99a990b4528d84484d1d04c168950171c6bfe8d Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Sat, 21 Mar 2026 21:49:08 +0700 Subject: [PATCH 03/15] feat(schedule): refactor schedule services and tests for schedule job - Seperate the utils function from schedule service. - Created comprehensive unit tests for all utility functions in `utils.test.ts`, covering various scenarios for cron expression validation, visual picker validation, and schedule resolution. - Ensured proper handling of edge cases and error messages for invalid inputs. --- packages/server/__mocks__/typeorm.ts | 22 + packages/server/jest.config.js | 13 + .../server/src/queue/ScheduleBeat.test.ts | 543 ++++++++++++++++++ .../server/src/queue/ScheduleExecutor.test.ts | 533 +++++++++++++++++ .../server/src/queue/ScheduleQueue.test.ts | 292 ++++++++++ .../src/services/chatflows/index.test.ts | 535 +++++++++++++++++ .../src/services/schedule/index.test.ts | 504 ++++++++++++++++ .../server/src/services/schedule/index.ts | 390 +------------ .../src/services/schedule/utils.test.ts | 526 +++++++++++++++++ .../server/src/services/schedule/utils.ts | 382 ++++++++++++ 10 files changed, 3372 insertions(+), 368 deletions(-) create mode 100644 packages/server/__mocks__/typeorm.ts create mode 100644 packages/server/src/queue/ScheduleBeat.test.ts create mode 100644 packages/server/src/queue/ScheduleExecutor.test.ts create mode 100644 packages/server/src/queue/ScheduleQueue.test.ts create mode 100644 packages/server/src/services/chatflows/index.test.ts create mode 100644 packages/server/src/services/schedule/index.test.ts create mode 100644 packages/server/src/services/schedule/utils.test.ts create mode 100644 packages/server/src/services/schedule/utils.ts diff --git a/packages/server/__mocks__/typeorm.ts b/packages/server/__mocks__/typeorm.ts new file mode 100644 index 00000000000..6462bee1445 --- /dev/null +++ b/packages/server/__mocks__/typeorm.ts @@ -0,0 +1,22 @@ +/** + * Manual mock for 'typeorm'. + * All decorator factories are replaced with no-ops so TypeORM entity classes + * can be defined in tests without a real database connection. + * Used by all server-package test files via jest.mock('typeorm'). + */ + +const decorator = (): (() => void) => () => {} + +module.exports = { + PrimaryGeneratedColumn: decorator, + PrimaryColumn: decorator, + CreateDateColumn: decorator, + UpdateDateColumn: decorator, + Index: decorator, + ManyToOne: decorator, + OneToMany: decorator, + OneToOne: decorator, + JoinColumn: decorator, + Unique: decorator, + DataSource: jest.fn() +} diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js index 8b8f1de1965..1e8b21f5140 100644 --- a/packages/server/jest.config.js +++ b/packages/server/jest.config.js @@ -18,6 +18,19 @@ module.exports = { // File extensions to recognize in module resolution moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + // uuid v10+ ships ESM-only; redirect to the CJS dist so Jest can require it. + // typeorm is not resolvable via pnpm symlinks in the test runner; redirect to + // the shared manual mock so all test files get the same decorator stubs without + // needing an inline jest.mock() factory. + moduleNameMapper: { + '^uuid$': '/node_modules/uuid/dist/index.js', + '^typeorm$': '/__mocks__/typeorm.ts' + }, + + // Include the package's own node_modules so that Jest can resolve + // symlinked pnpm dependencies when tests live inside src/ + modulePaths: ['/node_modules'], + // Display individual test results with the test suite hierarchy. verbose: true } diff --git a/packages/server/src/queue/ScheduleBeat.test.ts b/packages/server/src/queue/ScheduleBeat.test.ts new file mode 100644 index 00000000000..27dbb4319ba --- /dev/null +++ b/packages/server/src/queue/ScheduleBeat.test.ts @@ -0,0 +1,543 @@ +/** + * Unit tests for ScheduleBeat β€” schedule orchestrator. + * All external dependencies (node-cron, QueueManager, ScheduleExecutor, + * getRunningExpressApp, schedule service) are mocked. + */ + +// ─── Fixtures (created before mock factories so they can be referenced inside) ─ + +const mockTask = { stop: jest.fn() } +const mockScheduleQueue = { + upsertJobScheduler: jest.fn().mockResolvedValue(undefined), + removeJobScheduler: jest.fn().mockResolvedValue(undefined) +} +const mockSave = jest.fn().mockResolvedValue(undefined) +const mockFindOneBy = jest.fn() +const mockRepo = { findOneBy: mockFindOneBy, save: mockSave } +const mockAppDataSource = { getRepository: jest.fn().mockReturnValue(mockRepo) } +const mockAppServer = { + AppDataSource: mockAppDataSource, + nodesPool: { componentNodes: {} }, + telemetry: {}, + cachePool: {}, + usageCacheManager: {}, + sseStreamer: {} +} + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../database/entities/ScheduleRecord', () => ({ + ScheduleRecord: class ScheduleRecord {}, + ScheduleTriggerType: { AGENTFLOW: 'AGENTFLOW' } +})) +jest.mock('../utils/getRunningExpressApp', () => ({ + getRunningExpressApp: jest.fn().mockReturnValue(mockAppServer) +})) +jest.mock('./ScheduleQueue', () => ({ ScheduleQueue: class ScheduleQueue {} })) +jest.mock('./QueueManager', () => ({ + QueueManager: { + getInstance: jest.fn().mockReturnValue({ + getQueue: jest.fn().mockReturnValue(mockScheduleQueue) + }) + } +})) +jest.mock('./ScheduleExecutor', () => ({ executeScheduleJob: jest.fn().mockResolvedValue(undefined) })) +jest.mock('../services/schedule', () => ({ + __esModule: true, + default: { getEnabledSchedulesBatch: jest.fn().mockResolvedValue([]) } +})) +jest.mock('../Interface', () => ({ MODE: { QUEUE: 'queue' } })) +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() } +})) +jest.mock('node-cron', () => ({ + __esModule: true, + default: { + validate: jest.fn().mockReturnValue(true), + schedule: jest.fn().mockReturnValue(mockTask) + } +})) + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { ScheduleBeat } from './ScheduleBeat' +import { executeScheduleJob } from './ScheduleExecutor' +import scheduleService from '../services/schedule' +import { QueueManager } from './QueueManager' +import cron from 'node-cron' + +const mockExecuteScheduleJob = executeScheduleJob as jest.Mock +const mockGetEnabledSchedulesBatch = scheduleService.getEnabledSchedulesBatch as jest.Mock +const mockCronValidate = cron.validate as jest.Mock +const mockCronSchedule = cron.schedule as jest.Mock + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const makeRecord = (overrides: Record = {}) => ({ + id: 'rec-1', + cronExpression: '* * * * *', + timezone: 'UTC', + enabled: true, + targetId: 'flow-1', + workspaceId: 'ws-1', + ...overrides +}) + +/** Reset singleton and optionally set a MODE env var. */ +function resetSingleton(mode?: string) { + ;(ScheduleBeat as any).instance = undefined + delete process.env.MODE + if (mode) process.env.MODE = mode +} + +// ─── Global setup ───────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks() + resetSingleton() + // Re-establish default return values after clearAllMocks + mockAppDataSource.getRepository.mockReturnValue(mockRepo) + mockGetEnabledSchedulesBatch.mockResolvedValue([]) + mockCronValidate.mockReturnValue(true) + mockCronSchedule.mockReturnValue(mockTask) + mockScheduleQueue.upsertJobScheduler.mockResolvedValue(undefined) + mockScheduleQueue.removeJobScheduler.mockResolvedValue(undefined) + ;(QueueManager.getInstance as jest.Mock).mockReturnValue({ + getQueue: jest.fn().mockReturnValue(mockScheduleQueue) + }) + mockExecuteScheduleJob.mockResolvedValue(undefined) + mockFindOneBy.mockResolvedValue(null) + mockSave.mockResolvedValue(undefined) +}) + +afterEach(() => { + delete process.env.MODE +}) + +// ─── getInstance ────────────────────────────────────────────────────────────── + +describe('getInstance', () => { + it('returns the same instance on repeated calls', () => { + const a = ScheduleBeat.getInstance() + const b = ScheduleBeat.getInstance() + expect(a).toBe(b) + }) + + it('creates a fresh instance after singleton reset', () => { + const a = ScheduleBeat.getInstance() + resetSingleton() + const b = ScheduleBeat.getInstance() + expect(a).not.toBe(b) + }) +}) + +// ─── init (non-queue mode) ──────────────────────────────────────────────────── + +describe('init β€” non-queue mode', () => { + it('registers cron jobs for all enabled records on init', async () => { + mockGetEnabledSchedulesBatch.mockResolvedValueOnce([makeRecord()]).mockResolvedValueOnce([]) + const beat = ScheduleBeat.getInstance() + await beat.init() + expect(mockCronSchedule).toHaveBeenCalledTimes(1) + }) + + it('logs a warning about no distributed locking', async () => { + const logger = require('../utils/logger').default + const beat = ScheduleBeat.getInstance() + await beat.init() + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('non-queue mode')) + }) +}) + +// ─── init (queue mode) ─────────────────────────────────────────────────────── + +describe('init β€” queue mode', () => { + beforeEach(() => resetSingleton('queue')) + + it('upserts jobs via ScheduleQueue on init', async () => { + mockGetEnabledSchedulesBatch.mockResolvedValueOnce([makeRecord()]).mockResolvedValueOnce([]) + const beat = ScheduleBeat.getInstance() + await beat.init() + expect(mockScheduleQueue.upsertJobScheduler).toHaveBeenCalledTimes(1) + }) + + it('does not register any node-cron tasks in queue mode', async () => { + mockGetEnabledSchedulesBatch.mockResolvedValueOnce([makeRecord()]).mockResolvedValueOnce([]) + const beat = ScheduleBeat.getInstance() + await beat.init() + expect(mockCronSchedule).not.toHaveBeenCalled() + }) + + it('does not emit the non-queue warning in queue mode', async () => { + const logger = require('../utils/logger').default + const beat = ScheduleBeat.getInstance() + await beat.init() + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('non-queue mode')) + }) +}) + +// ─── onScheduleChanged β€” delete ─────────────────────────────────────────────── + +describe('onScheduleChanged β€” delete action', () => { + it('removes a registered cron job (non-queue mode)', async () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any).cronJobs.set('rec-1', mockTask) + + await beat.onScheduleChanged('rec-1', 'delete') + + expect(mockTask.stop).toHaveBeenCalled() + expect((beat as any).cronJobs.has('rec-1')).toBe(false) + }) + + it('removes the queue job scheduler (queue mode)', async () => { + resetSingleton('queue') + const beat = ScheduleBeat.getInstance() + + await beat.onScheduleChanged('rec-1', 'delete') + + expect(mockScheduleQueue.removeJobScheduler).toHaveBeenCalledWith('rec-1') + }) + + it('does not query the database for a delete action', async () => { + const beat = ScheduleBeat.getInstance() + await beat.onScheduleChanged('rec-1', 'delete') + expect(mockFindOneBy).not.toHaveBeenCalled() + }) +}) + +// ─── onScheduleChanged β€” upsert ─────────────────────────────────────────────── + +describe('onScheduleChanged β€” upsert action', () => { + it('removes job when the record is not found', async () => { + resetSingleton('queue') + mockFindOneBy.mockResolvedValue(null) + const beat = ScheduleBeat.getInstance() + + await beat.onScheduleChanged('rec-1', 'upsert') + + expect(mockScheduleQueue.removeJobScheduler).toHaveBeenCalledWith('rec-1') + expect(mockScheduleQueue.upsertJobScheduler).not.toHaveBeenCalled() + }) + + it('removes job when the record is disabled', async () => { + resetSingleton('queue') + mockFindOneBy.mockResolvedValue(makeRecord({ enabled: false })) + const beat = ScheduleBeat.getInstance() + + await beat.onScheduleChanged('rec-1', 'upsert') + + expect(mockScheduleQueue.removeJobScheduler).toHaveBeenCalledWith('rec-1') + expect(mockScheduleQueue.upsertJobScheduler).not.toHaveBeenCalled() + }) + + it('upserts the queue job when the record is enabled (queue mode)', async () => { + resetSingleton('queue') + const record = makeRecord({ enabled: true }) + mockFindOneBy.mockResolvedValue(record) + const beat = ScheduleBeat.getInstance() + + await beat.onScheduleChanged('rec-1', 'upsert') + + expect(mockScheduleQueue.upsertJobScheduler).toHaveBeenCalledWith(record) + }) + + it('registers a cron job when the record is enabled (non-queue mode)', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ enabled: true })) + const beat = ScheduleBeat.getInstance() + + await beat.onScheduleChanged('rec-1', 'upsert') + + expect(mockCronSchedule).toHaveBeenCalled() + }) + + it('logs the error and does not throw on unexpected failure', async () => { + const logger = require('../utils/logger').default + resetSingleton('queue') + mockFindOneBy.mockRejectedValue(new Error('db fail')) + const beat = ScheduleBeat.getInstance() + + await expect(beat.onScheduleChanged('rec-1', 'upsert')).resolves.toBeUndefined() + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('onScheduleChanged error')) + }) +}) + +// ─── shutdown ───────────────────────────────────────────────────────────────── + +describe('shutdown', () => { + it('stops all registered cron tasks', async () => { + const beat = ScheduleBeat.getInstance() + const task1 = { stop: jest.fn() } + const task2 = { stop: jest.fn() } + ;(beat as any).cronJobs.set('rec-1', task1) + ;(beat as any).cronJobs.set('rec-2', task2) + + await beat.shutdown() + + expect(task1.stop).toHaveBeenCalled() + expect(task2.stop).toHaveBeenCalled() + }) + + it('clears the cronJobs map after shutdown', async () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any).cronJobs.set('rec-1', { stop: jest.fn() }) + + await beat.shutdown() + + expect((beat as any).cronJobs.size).toBe(0) + }) + + it('resolves without error when there are no cron tasks', async () => { + const beat = ScheduleBeat.getInstance() + await expect(beat.shutdown()).resolves.toBeUndefined() + }) +}) + +// ─── _syncAllJobs β€” non-queue mode ──────────────────────────────────────────── + +describe('_syncAllJobs β€” non-queue mode', () => { + it('stops and clears existing cron jobs before syncing', async () => { + const beat = ScheduleBeat.getInstance() + const existingTask = { stop: jest.fn() } + ;(beat as any).cronJobs.set('old-rec', existingTask) + + await (beat as any)._syncAllJobs() + + expect(existingTask.stop).toHaveBeenCalled() + expect((beat as any).cronJobs.has('old-rec')).toBe(false) + }) + + it('registers all records returned in the first batch', async () => { + const beat = ScheduleBeat.getInstance() + mockGetEnabledSchedulesBatch.mockResolvedValueOnce([makeRecord({ id: 'r1' }), makeRecord({ id: 'r2' })]).mockResolvedValueOnce([]) + + await (beat as any)._syncAllJobs() + + expect(mockCronSchedule).toHaveBeenCalledTimes(2) + }) + + it('pages through multiple batches until empty', async () => { + const beat = ScheduleBeat.getInstance() + mockGetEnabledSchedulesBatch + .mockResolvedValueOnce([makeRecord({ id: 'r1' })]) + .mockResolvedValueOnce([makeRecord({ id: 'r2' })]) + .mockResolvedValueOnce([]) + + await (beat as any)._syncAllJobs() + + expect(mockCronSchedule).toHaveBeenCalledTimes(2) + }) + + it('advances skip by batch size on each page', async () => { + const beat = ScheduleBeat.getInstance() + mockGetEnabledSchedulesBatch + .mockResolvedValueOnce([makeRecord()]) + .mockResolvedValueOnce([makeRecord({ id: 'r2' })]) + .mockResolvedValueOnce([]) + + await (beat as any)._syncAllJobs() + + expect(mockGetEnabledSchedulesBatch).toHaveBeenNthCalledWith(1, 0) + expect(mockGetEnabledSchedulesBatch).toHaveBeenNthCalledWith(2, 1) + expect(mockGetEnabledSchedulesBatch).toHaveBeenNthCalledWith(3, 2) + }) + + it('registers no jobs when there are no enabled schedules', async () => { + const beat = ScheduleBeat.getInstance() + await (beat as any)._syncAllJobs() + expect(mockCronSchedule).not.toHaveBeenCalled() + }) +}) + +// ─── _syncAllJobs β€” queue mode ──────────────────────────────────────────────── + +describe('_syncAllJobs β€” queue mode', () => { + beforeEach(() => resetSingleton('queue')) + + it('does not stop existing cron jobs in queue mode', async () => { + const beat = ScheduleBeat.getInstance() + const existingTask = { stop: jest.fn() } + ;(beat as any).cronJobs.set('old-rec', existingTask) + + await (beat as any)._syncAllJobs() + + expect(existingTask.stop).not.toHaveBeenCalled() + }) + + it('upserts all records via ScheduleQueue', async () => { + const beat = ScheduleBeat.getInstance() + mockGetEnabledSchedulesBatch.mockResolvedValueOnce([makeRecord({ id: 'r1' }), makeRecord({ id: 'r2' })]).mockResolvedValueOnce([]) + + await (beat as any)._syncAllJobs() + + expect(mockScheduleQueue.upsertJobScheduler).toHaveBeenCalledTimes(2) + expect(mockCronSchedule).not.toHaveBeenCalled() + }) +}) + +// ─── _upsertCronJob ─────────────────────────────────────────────────────────── + +describe('_upsertCronJob', () => { + it('registers a new cron task with correct expression and timezone', () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any)._upsertCronJob(makeRecord()) + + expect(mockCronSchedule).toHaveBeenCalledWith('* * * * *', expect.any(Function), { timezone: 'UTC' }) + expect((beat as any).cronJobs.get('rec-1')).toBe(mockTask) + }) + + it('stops the existing task before registering a replacement', () => { + const beat = ScheduleBeat.getInstance() + const oldTask = { stop: jest.fn() } + ;(beat as any).cronJobs.set('rec-1', oldTask) + ;(beat as any)._upsertCronJob(makeRecord()) + + expect(oldTask.stop).toHaveBeenCalled() + expect((beat as any).cronJobs.get('rec-1')).toBe(mockTask) + }) + + it('skips registration when cron expression is invalid', () => { + const beat = ScheduleBeat.getInstance() + mockCronValidate.mockReturnValue(false) + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: 'not-valid' })) + + expect(mockCronSchedule).not.toHaveBeenCalled() + expect((beat as any).cronJobs.has('rec-1')).toBe(false) + }) + + it('defaults timezone to UTC when record.timezone is null', () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any)._upsertCronJob(makeRecord({ timezone: null })) + + expect(mockCronSchedule).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { timezone: 'UTC' }) + }) + + it('fires _onCronFire when the cron task triggers', async () => { + const beat = ScheduleBeat.getInstance() + const onCronFire = jest.spyOn(beat as any, '_onCronFire').mockResolvedValue(undefined) + + ;(beat as any)._upsertCronJob(makeRecord()) + + // Extract and invoke the cron callback captured by cron.schedule + const cronCallback = mockCronSchedule.mock.calls[0][1] as () => void + cronCallback() + // Allow any pending microtasks to flush + await new Promise((r) => setImmediate(r)) + + expect(onCronFire).toHaveBeenCalledWith('rec-1') + onCronFire.mockRestore() + }) +}) + +// ─── _removeCronJob ─────────────────────────────────────────────────────────── + +describe('_removeCronJob', () => { + it('stops and removes an existing cron task', () => { + const beat = ScheduleBeat.getInstance() + const task = { stop: jest.fn() } + ;(beat as any).cronJobs.set('rec-1', task) + ;(beat as any)._removeCronJob('rec-1') + + expect(task.stop).toHaveBeenCalled() + expect((beat as any).cronJobs.has('rec-1')).toBe(false) + }) + + it('is a no-op when the record has no registered task', () => { + const beat = ScheduleBeat.getInstance() + expect(() => (beat as any)._removeCronJob('nonexistent')).not.toThrow() + }) +}) + +// ─── _onCronFire ────────────────────────────────────────────────────────────── + +describe('_onCronFire', () => { + it('calls executeScheduleJob with the correct execution context', async () => { + const beat = ScheduleBeat.getInstance() + + await (beat as any)._onCronFire('rec-1') + + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + { + appDataSource: mockAppDataSource, + componentNodes: {}, + telemetry: {}, + cachePool: {}, + usageCacheManager: {}, + sseStreamer: {} + }, + 'rec-1', + expect.objectContaining({ + onRecordNotFoundOrDisabled: expect.any(Function), + onRecordExpiredOrInvalid: expect.any(Function) + }) + ) + }) + + it('onRecordNotFoundOrDisabled callback removes the cron job', async () => { + const beat = ScheduleBeat.getInstance() + const task = { stop: jest.fn() } + ;(beat as any).cronJobs.set('rec-1', task) + + let capturedCallbacks: any + mockExecuteScheduleJob.mockImplementation(async (_ctx: any, _id: string, callbacks: any) => { + capturedCallbacks = callbacks + }) + + await (beat as any)._onCronFire('rec-1') + capturedCallbacks.onRecordNotFoundOrDisabled('rec-1') + + expect(task.stop).toHaveBeenCalled() + expect((beat as any).cronJobs.has('rec-1')).toBe(false) + }) + + it('onRecordExpiredOrInvalid callback sets enabled=false and saves the record', async () => { + const beat = ScheduleBeat.getInstance() + const record = makeRecord({ enabled: true }) as any + + let capturedCallbacks: any + mockExecuteScheduleJob.mockImplementation(async (_ctx: any, _id: string, callbacks: any) => { + capturedCallbacks = callbacks + }) + + await (beat as any)._onCronFire('rec-1') + await capturedCallbacks.onRecordExpiredOrInvalid(record) + + expect(record.enabled).toBe(false) + expect(mockSave).toHaveBeenCalledWith(record) + }) + + it('onRecordExpiredOrInvalid callback removes the cron job', async () => { + const beat = ScheduleBeat.getInstance() + const task = { stop: jest.fn() } + ;(beat as any).cronJobs.set('rec-1', task) + const record = makeRecord({ enabled: true }) as any + + let capturedCallbacks: any + mockExecuteScheduleJob.mockImplementation(async (_ctx: any, _id: string, callbacks: any) => { + capturedCallbacks = callbacks + }) + + await (beat as any)._onCronFire('rec-1') + await capturedCallbacks.onRecordExpiredOrInvalid(record) + + expect(task.stop).toHaveBeenCalled() + expect((beat as any).cronJobs.has('rec-1')).toBe(false) + }) + + it('uses record.id (not the fired schedule id) when removing job in onRecordExpiredOrInvalid', async () => { + const beat = ScheduleBeat.getInstance() + const task = { stop: jest.fn() } + ;(beat as any).cronJobs.set('rec-different', task) + const record = makeRecord({ id: 'rec-different', enabled: true }) as any + + let capturedCallbacks: any + mockExecuteScheduleJob.mockImplementation(async (_ctx: any, _id: string, callbacks: any) => { + capturedCallbacks = callbacks + }) + + await (beat as any)._onCronFire('rec-different') + await capturedCallbacks.onRecordExpiredOrInvalid(record) + + expect(task.stop).toHaveBeenCalled() + }) +}) diff --git a/packages/server/src/queue/ScheduleExecutor.test.ts b/packages/server/src/queue/ScheduleExecutor.test.ts new file mode 100644 index 00000000000..8c1ecbf64ec --- /dev/null +++ b/packages/server/src/queue/ScheduleExecutor.test.ts @@ -0,0 +1,533 @@ +/** + * Unit tests for ScheduleExecutor β€” shared schedule job execution logic. + * All external dependencies (TypeORM, agentflow runner, schedule service) + * are mocked so no real database or Express app is needed. + */ + +// ─── Infrastructure mocks ───────────────────────────────────────────────────── + +jest.mock('../database/entities/ScheduleRecord', () => ({ + ScheduleRecord: class ScheduleRecord {}, + ScheduleTriggerType: { AGENTFLOW: 'AGENTFLOW' } +})) +jest.mock('../database/entities/ScheduleTriggerLog', () => ({ + ScheduleTriggerLog: class ScheduleTriggerLog {}, + ScheduleTriggerStatus: { + QUEUED: 'QUEUED', + RUNNING: 'RUNNING', + SUCCEEDED: 'SUCCEEDED', + FAILED: 'FAILED', + SKIPPED: 'SKIPPED' + } +})) +jest.mock('../database/entities/ChatFlow', () => ({ ChatFlow: class ChatFlow {} })) +jest.mock('../utils/buildAgentflow', () => ({ executeAgentFlow: jest.fn() })) +jest.mock('../services/schedule', () => ({ + __esModule: true, + default: { + isDefaultInputValid: jest.fn().mockReturnValue(true), + createTriggerLog: jest.fn(), + updateTriggerLog: jest.fn(), + updateScheduleAfterRun: jest.fn() + } +})) +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() } +})) +jest.mock('flowise-components', () => ({}), { virtual: true }) +jest.mock('../Interface', () => ({}), { virtual: true }) +jest.mock('../utils/telemetry', () => ({ Telemetry: class Telemetry {} })) +jest.mock('../CachePool', () => ({ CachePool: class CachePool {} })) +jest.mock('../UsageCacheManager', () => ({ UsageCacheManager: class UsageCacheManager {} })) + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { executeScheduleJob } from './ScheduleExecutor' +import { executeAgentFlow } from '../utils/buildAgentflow' +import scheduleService from '../services/schedule' +import { ScheduleTriggerType } from '../database/entities/ScheduleRecord' +import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' + +const mockExecuteAgentFlow = executeAgentFlow as jest.Mock + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Minimal ScheduleRecord-like object */ +const makeRecord = (overrides: Record = {}) => ({ + id: 'rec-1', + targetId: 'flow-1', + triggerType: ScheduleTriggerType.AGENTFLOW, + cronExpression: '* * * * *', + timezone: 'UTC', + enabled: true, + workspaceId: 'ws-1', + defaultInput: 'hello', + endDate: undefined as Date | undefined, + nextRunAt: undefined as Date | undefined, + ...overrides +}) + +/** Minimal ChatFlow-like object of AGENTFLOW type */ +const makeChatFlow = (overrides: Record = {}) => ({ + id: 'flow-1', + type: 'AGENTFLOW', + workspaceId: 'ws-1', + ...overrides +}) + +// ─── Test fixture setup ─────────────────────────────────────────────────────── + +let mockFindOneBy: jest.Mock +let mockAppDataSource: { getRepository: jest.Mock } +let mockCtx: any + +beforeEach(() => { + jest.clearAllMocks() + + mockFindOneBy = jest.fn() + mockAppDataSource = { + getRepository: jest.fn().mockReturnValue({ findOneBy: mockFindOneBy }) + } + mockCtx = { + appDataSource: mockAppDataSource, + componentNodes: {}, + telemetry: {}, + cachePool: {}, + usageCacheManager: {}, + sseStreamer: {} + } + ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(true) + ;(scheduleService.createTriggerLog as jest.Mock).mockResolvedValue({ id: 'log-1' }) + ;(scheduleService.updateTriggerLog as jest.Mock).mockResolvedValue(undefined) + ;(scheduleService.updateScheduleAfterRun as jest.Mock).mockResolvedValue(undefined) +}) + +// ─── executeScheduleJob: record-not-found branch ────────────────────────────── + +describe('executeScheduleJob β€” record not found', () => { + it('returns undefined when the record does not exist', async () => { + mockFindOneBy.mockResolvedValue(null) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(result).toBeUndefined() + }) + + it('calls onRecordNotFoundOrDisabled when record is missing', async () => { + mockFindOneBy.mockResolvedValue(null) + const onRecordNotFoundOrDisabled = jest.fn() + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordNotFoundOrDisabled }) + + expect(onRecordNotFoundOrDisabled).toHaveBeenCalledWith('rec-1') + }) + + it('does not create a trigger log when the record is missing', async () => { + mockFindOneBy.mockResolvedValue(null) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).not.toHaveBeenCalled() + }) + + it('does not throw when no callbacks are provided', async () => { + mockFindOneBy.mockResolvedValue(null) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).resolves.toBeUndefined() + }) +}) + +// ─── executeScheduleJob: record disabled branch ─────────────────────────────── + +describe('executeScheduleJob β€” record disabled', () => { + it('returns undefined when the record is disabled', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ enabled: false })) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(result).toBeUndefined() + }) + + it('calls onRecordNotFoundOrDisabled with the record id', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ enabled: false })) + const onRecordNotFoundOrDisabled = jest.fn() + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordNotFoundOrDisabled }) + + expect(onRecordNotFoundOrDisabled).toHaveBeenCalledWith('rec-1') + }) + + it('creates a SKIPPED trigger log for a disabled record', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ enabled: false })) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).toHaveBeenCalledWith( + expect.objectContaining({ + appDataSource: mockAppDataSource, + scheduleRecordId: 'rec-1', + status: ScheduleTriggerStatus.SKIPPED, + targetId: 'flow-1', + workspaceId: 'ws-1' + }) + ) + }) + + it('falls back to AGENTFLOW trigger type when record.triggerType is null', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ enabled: false, triggerType: null })) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).toHaveBeenCalledWith( + expect.objectContaining({ triggerType: ScheduleTriggerType.AGENTFLOW }) + ) + }) +}) + +// ─── executeScheduleJob: expired / invalid-input branch ─────────────────────── + +describe('executeScheduleJob β€” expired or invalid input', () => { + it('returns undefined when end date has passed', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ endDate: new Date(Date.now() - 60_000) })) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(result).toBeUndefined() + }) + + it('calls onRecordExpiredOrInvalid when end date has passed', async () => { + const record = makeRecord({ endDate: new Date(Date.now() - 60_000) }) + mockFindOneBy.mockResolvedValue(record) + const onRecordExpiredOrInvalid = jest.fn() + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordExpiredOrInvalid }) + + expect(onRecordExpiredOrInvalid).toHaveBeenCalledWith(record) + }) + + it('creates a SKIPPED log when end date has passed', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ endDate: new Date(Date.now() - 60_000) })) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).toHaveBeenCalledWith(expect.objectContaining({ status: ScheduleTriggerStatus.SKIPPED })) + }) + + it('returns undefined when default input is invalid', async () => { + mockFindOneBy.mockResolvedValue(makeRecord()) + ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(result).toBeUndefined() + }) + + it('calls onRecordExpiredOrInvalid when default input is invalid', async () => { + const record = makeRecord() + mockFindOneBy.mockResolvedValue(record) + ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + const onRecordExpiredOrInvalid = jest.fn() + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordExpiredOrInvalid }) + + expect(onRecordExpiredOrInvalid).toHaveBeenCalledWith(record) + }) + + it('creates a SKIPPED log when default input is invalid', async () => { + mockFindOneBy.mockResolvedValue(makeRecord()) + ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).toHaveBeenCalledWith(expect.objectContaining({ status: ScheduleTriggerStatus.SKIPPED })) + }) + + it('does not execute the agentflow when input is invalid', async () => { + mockFindOneBy.mockResolvedValue(makeRecord()) + ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).not.toHaveBeenCalled() + }) +}) + +// ─── executeScheduleJob: nextRunAt guard ────────────────────────────────────── + +describe('executeScheduleJob β€” nextRunAt guard', () => { + it('returns undefined when nextRunAt is in the future', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ nextRunAt: new Date(Date.now() + 60_000) })) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(result).toBeUndefined() + }) + + it('creates a SKIPPED log when nextRunAt is in the future', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ nextRunAt: new Date(Date.now() + 60_000) })) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).toHaveBeenCalledWith(expect.objectContaining({ status: ScheduleTriggerStatus.SKIPPED })) + }) + + it('does NOT call onRecordExpiredOrInvalid for the nextRunAt guard', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ nextRunAt: new Date(Date.now() + 60_000) })) + const onRecordExpiredOrInvalid = jest.fn() + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordExpiredOrInvalid }) + + expect(onRecordExpiredOrInvalid).not.toHaveBeenCalled() + }) + + it('does not execute the agentflow when nextRunAt is in the future', async () => { + mockFindOneBy.mockResolvedValue(makeRecord({ nextRunAt: new Date(Date.now() + 60_000) })) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).not.toHaveBeenCalled() + }) + + it('proceeds to execution when nextRunAt is in the past', async () => { + const record = makeRecord({ nextRunAt: new Date(Date.now() - 60_000) }) + const chatflow = makeChatFlow() + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(chatflow) + mockExecuteAgentFlow.mockResolvedValue({}) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).toHaveBeenCalled() + expect(result).toBeDefined() + }) +}) + +// ─── executeScheduleJob: successful execution ───────────────────────────────── + +describe('executeScheduleJob β€” successful execution', () => { + it('returns the agentflow result', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({ executionId: 'exec-1', answer: 'done' }) + + const result = await executeScheduleJob(mockCtx, 'rec-1') + + expect(result).toEqual({ executionId: 'exec-1', answer: 'done' }) + }) + + it('creates a RUNNING trigger log before executing', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.createTriggerLog).toHaveBeenCalledWith(expect.objectContaining({ status: ScheduleTriggerStatus.RUNNING })) + }) + + it('updates the trigger log with SUCCEEDED status and executionId', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({ executionId: 'exec-42' }) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.updateTriggerLog).toHaveBeenCalledWith( + mockAppDataSource, + 'log-1', + expect.objectContaining({ + status: ScheduleTriggerStatus.SUCCEEDED, + executionId: 'exec-42', + elapsedTimeMs: expect.any(Number) + }) + ) + }) + + it('calls updateScheduleAfterRun with cron and timezone', async () => { + const record = makeRecord({ cronExpression: '0 9 * * 1-5', timezone: 'America/New_York' }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.updateScheduleAfterRun).toHaveBeenCalledWith(mockAppDataSource, 'rec-1', '0 9 * * 1-5', 'America/New_York') + }) + + it('uses record defaultInput as the agentflow question', async () => { + const record = makeRecord({ defaultInput: 'run daily report' }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).toHaveBeenCalledWith( + expect.objectContaining({ + incomingInput: expect.objectContaining({ question: 'run daily report' }) + }) + ) + }) + + it('passes correct flags to executeAgentFlow', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).toHaveBeenCalledWith( + expect.objectContaining({ + isInternal: true, + isTool: true, + incomingInput: expect.objectContaining({ streaming: false }) + }) + ) + }) + + it('uses chatflow.workspaceId when set', async () => { + mockFindOneBy + .mockResolvedValueOnce(makeRecord({ workspaceId: 'ws-record' })) + .mockResolvedValueOnce(makeChatFlow({ workspaceId: 'ws-flow' })) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-flow' })) + }) + + it('falls back to record.workspaceId when chatflow.workspaceId is null', async () => { + mockFindOneBy + .mockResolvedValueOnce(makeRecord({ workspaceId: 'ws-record' })) + .mockResolvedValueOnce(makeChatFlow({ workspaceId: null })) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-record' })) + }) + + it('sets executionId to undefined when result has no executionId field', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({ answer: 'no id here' }) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(scheduleService.updateTriggerLog).toHaveBeenCalledWith( + mockAppDataSource, + 'log-1', + expect.objectContaining({ executionId: undefined }) + ) + }) + + it('uses empty string as question when defaultInput is falsy', async () => { + const record = makeRecord({ defaultInput: '' }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + expect(mockExecuteAgentFlow).toHaveBeenCalledWith( + expect.objectContaining({ incomingInput: expect.objectContaining({ question: '' }) }) + ) + }) +}) + +// ─── executeScheduleJob: ChatFlow not found ─────────────────────────────────── + +describe('executeScheduleJob β€” ChatFlow not found', () => { + it('re-throws an error when the ChatFlow does not exist', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(null) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow('ChatFlow flow-1 not found') + }) + + it('updates trigger log with FAILED status when ChatFlow is missing', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(null) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow() + + expect(scheduleService.updateTriggerLog).toHaveBeenCalledWith( + mockAppDataSource, + 'log-1', + expect.objectContaining({ + status: ScheduleTriggerStatus.FAILED, + error: expect.stringContaining('not found') + }) + ) + }) + + it('does not call updateScheduleAfterRun when ChatFlow is missing', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(null) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow() + + expect(scheduleService.updateScheduleAfterRun).not.toHaveBeenCalled() + }) +}) + +// ─── executeScheduleJob: ChatFlow wrong type ────────────────────────────────── + +describe('executeScheduleJob β€” ChatFlow wrong type', () => { + it('re-throws an error when ChatFlow is not of type AGENTFLOW', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow({ type: 'CHATFLOW' })) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow('not of type AGENTFLOW') + }) + + it('updates trigger log with FAILED status', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow({ type: 'CHATFLOW' })) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow() + + expect(scheduleService.updateTriggerLog).toHaveBeenCalledWith( + mockAppDataSource, + 'log-1', + expect.objectContaining({ status: ScheduleTriggerStatus.FAILED }) + ) + }) +}) + +// ─── executeScheduleJob: agentflow execution error ──────────────────────────── + +describe('executeScheduleJob β€” agentflow execution error', () => { + it('re-throws the error from executeAgentFlow', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockRejectedValue(new Error('execution failed')) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow('execution failed') + }) + + it('updates trigger log with FAILED, error message, and elapsedTimeMs', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockRejectedValue(new Error('execution failed')) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow() + + expect(scheduleService.updateTriggerLog).toHaveBeenCalledWith( + mockAppDataSource, + 'log-1', + expect.objectContaining({ + status: ScheduleTriggerStatus.FAILED, + error: 'execution failed', + elapsedTimeMs: expect.any(Number) + }) + ) + }) + + it('handles non-Error thrown values (string)', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockRejectedValue('something went wrong') + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toBe('something went wrong') + + expect(scheduleService.updateTriggerLog).toHaveBeenCalledWith( + mockAppDataSource, + 'log-1', + expect.objectContaining({ status: ScheduleTriggerStatus.FAILED, error: 'something went wrong' }) + ) + }) + + it('does not call updateScheduleAfterRun on execution failure', async () => { + mockFindOneBy.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockRejectedValue(new Error('fail')) + + await expect(executeScheduleJob(mockCtx, 'rec-1')).rejects.toThrow() + + expect(scheduleService.updateScheduleAfterRun).not.toHaveBeenCalled() + }) +}) diff --git a/packages/server/src/queue/ScheduleQueue.test.ts b/packages/server/src/queue/ScheduleQueue.test.ts new file mode 100644 index 00000000000..1c690ca2550 --- /dev/null +++ b/packages/server/src/queue/ScheduleQueue.test.ts @@ -0,0 +1,292 @@ +/** + * Unit tests for ScheduleQueue. + * All external dependencies (BullMQ, RedisEventPublisher, ScheduleExecutor) + * are mocked so no real Redis or database connection is needed. + */ + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockBullQueue = { + upsertJobScheduler: jest.fn().mockResolvedValue(undefined), + removeJobScheduler: jest.fn().mockResolvedValue(undefined) +} +const mockSave = jest.fn().mockResolvedValue(undefined) +const mockRepo = { save: mockSave } +const mockAppDataSource = { getRepository: jest.fn().mockReturnValue(mockRepo) } +const mockRedisPublisher = { connect: jest.fn().mockResolvedValue(undefined) } + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('bullmq', () => ({ + Queue: jest.fn().mockImplementation(() => mockBullQueue), + QueueEvents: jest.fn().mockImplementation(() => ({})), + Worker: jest.fn().mockImplementation(() => ({})) +})) +jest.mock('./RedisEventPublisher', () => ({ + RedisEventPublisher: jest.fn().mockImplementation(() => mockRedisPublisher) +})) +jest.mock('./ScheduleExecutor', () => ({ executeScheduleJob: jest.fn().mockResolvedValue(undefined) })) +jest.mock('../database/entities/ScheduleRecord', () => ({ + ScheduleRecord: class ScheduleRecord {} +})) +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() } +})) +jest.mock('flowise-components', () => ({}), { virtual: true }) +jest.mock('../Interface', () => ({}), { virtual: true }) +jest.mock('../Interface.Schedule', () => ({}), { virtual: true }) +jest.mock('../utils/telemetry', () => ({ Telemetry: class Telemetry {} })) +jest.mock('../CachePool', () => ({ CachePool: class CachePool {} })) +jest.mock('../UsageCacheManager', () => ({ UsageCacheManager: class UsageCacheManager {} })) + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { ScheduleQueue } from './ScheduleQueue' +import { executeScheduleJob } from './ScheduleExecutor' +import { RedisEventPublisher } from './RedisEventPublisher' + +const mockExecuteScheduleJob = executeScheduleJob as jest.Mock + +// ─── Factory helpers ────────────────────────────────────────────────────────── + +const CONNECTION = { host: 'localhost', port: 6379 } +const OPTIONS = { + appDataSource: mockAppDataSource as any, + telemetry: {} as any, + cachePool: {} as any, + componentNodes: {} as any, + usageCacheManager: {} as any +} + +function makeQueue(name = 'schedule') { + return new ScheduleQueue(name, CONNECTION, OPTIONS) +} + +const makeRecord = (overrides: Record = {}) => ({ + id: 'rec-1', + targetId: 'flow-1', + cronExpression: '* * * * *', + timezone: 'UTC', + defaultInput: 'hello', + workspaceId: 'ws-1', + enabled: true, + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() + mockBullQueue.upsertJobScheduler.mockResolvedValue(undefined) + mockBullQueue.removeJobScheduler.mockResolvedValue(undefined) + mockAppDataSource.getRepository.mockReturnValue(mockRepo) + mockSave.mockResolvedValue(undefined) + mockExecuteScheduleJob.mockResolvedValue(undefined) + mockRedisPublisher.connect.mockResolvedValue(undefined) +}) + +// ─── constructor ────────────────────────────────────────────────────────────── + +describe('constructor', () => { + it('constructs without throwing', () => { + expect(() => makeQueue()).not.toThrow() + }) + + it('creates a RedisEventPublisher and calls connect()', () => { + makeQueue() + expect(RedisEventPublisher).toHaveBeenCalledTimes(1) + expect(mockRedisPublisher.connect).toHaveBeenCalledTimes(1) + }) +}) + +// ─── getQueueName ───────────────────────────────────────────────────────────── + +describe('getQueueName', () => { + it('returns the name passed to the constructor', () => { + expect(makeQueue('my-queue').getQueueName()).toBe('my-queue') + }) +}) + +// ─── getQueue ───────────────────────────────────────────────────────────────── + +describe('getQueue', () => { + it('returns the underlying BullMQ Queue instance', () => { + const q = makeQueue() + expect(q.getQueue()).toBe(mockBullQueue) + }) +}) + +// ─── processJob ─────────────────────────────────────────────────────────────── + +describe('processJob', () => { + it('calls executeScheduleJob with the scheduleRecordId from job data', async () => { + const q = makeQueue() + await q.processJob({ scheduleRecordId: 'rec-1' } as any) + + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + expect.objectContaining({ appDataSource: mockAppDataSource }), + 'rec-1', + expect.objectContaining({ + onRecordNotFoundOrDisabled: expect.any(Function), + onRecordExpiredOrInvalid: expect.any(Function) + }) + ) + }) + + it('passes the RedisEventPublisher instance as sseStreamer', async () => { + const q = makeQueue() + await q.processJob({ scheduleRecordId: 'rec-1' } as any) + + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + expect.objectContaining({ sseStreamer: mockRedisPublisher }), + expect.anything(), + expect.anything() + ) + }) + + it('returns the result from executeScheduleJob', async () => { + mockExecuteScheduleJob.mockResolvedValue({ answer: 'done' }) + const q = makeQueue() + const result = await q.processJob({ scheduleRecordId: 'rec-1' } as any) + expect(result).toEqual({ answer: 'done' }) + }) + + it('onRecordNotFoundOrDisabled callback removes the job scheduler', async () => { + const q = makeQueue() + let capturedCallbacks: any + + mockExecuteScheduleJob.mockImplementation(async (_ctx: any, _id: string, callbacks: any) => { + capturedCallbacks = callbacks + }) + + await q.processJob({ scheduleRecordId: 'rec-1' } as any) + await capturedCallbacks.onRecordNotFoundOrDisabled() + + expect(mockBullQueue.removeJobScheduler).toHaveBeenCalledWith('schedule:rec-1') + }) + + it('onRecordExpiredOrInvalid callback sets enabled=false, saves record, and removes job scheduler', async () => { + const q = makeQueue() + const record = makeRecord({ enabled: true }) as any + let capturedCallbacks: any + + mockExecuteScheduleJob.mockImplementation(async (_ctx: any, _id: string, callbacks: any) => { + capturedCallbacks = callbacks + }) + + await q.processJob({ scheduleRecordId: 'rec-1' } as any) + await capturedCallbacks.onRecordExpiredOrInvalid(record) + + expect(record.enabled).toBe(false) + expect(mockSave).toHaveBeenCalledWith(record) + expect(mockBullQueue.removeJobScheduler).toHaveBeenCalledWith('schedule:rec-1') + }) +}) + +// ─── upsertJobScheduler ─────────────────────────────────────────────────────── + +describe('upsertJobScheduler', () => { + it('calls queue.upsertJobScheduler with the correct scheduler id', async () => { + const q = makeQueue() + await q.upsertJobScheduler(makeRecord() as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith('schedule:rec-1', expect.anything(), expect.anything()) + }) + + it('sets the repeat pattern and timezone from the record', async () => { + const record = makeRecord({ cronExpression: '0 9 * * 1-5', timezone: 'America/New_York' }) + const q = makeQueue() + await q.upsertJobScheduler(record as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith( + expect.anything(), + { pattern: '0 9 * * 1-5', tz: 'America/New_York' }, + expect.anything() + ) + }) + + it('defaults timezone to UTC when record.timezone is null', async () => { + const q = makeQueue() + await q.upsertJobScheduler(makeRecord({ timezone: null }) as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ tz: 'UTC' }), + expect.anything() + ) + }) + + it('includes scheduleRecordId, targetId, and workspaceId in job data', async () => { + const q = makeQueue() + await q.upsertJobScheduler(makeRecord() as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + scheduleRecordId: 'rec-1', + targetId: 'flow-1', + workspaceId: 'ws-1' + }) + }) + ) + }) + + it('sets defaultInput from the record', async () => { + const q = makeQueue() + await q.upsertJobScheduler(makeRecord({ defaultInput: 'run report' }) as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ data: expect.objectContaining({ defaultInput: 'run report' }) }) + ) + }) + + it('sets defaultInput to undefined when record.defaultInput is null', async () => { + const q = makeQueue() + await q.upsertJobScheduler(makeRecord({ defaultInput: null }) as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ data: expect.objectContaining({ defaultInput: undefined }) }) + ) + }) + + it('uses the scheduler id as the job name', async () => { + const q = makeQueue() + await q.upsertJobScheduler(makeRecord() as any) + + expect(mockBullQueue.upsertJobScheduler).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ name: 'schedule:rec-1' }) + ) + }) +}) + +// ─── removeJobScheduler ─────────────────────────────────────────────────────── + +describe('removeJobScheduler', () => { + it('calls queue.removeJobScheduler with the correct scheduler id', async () => { + const q = makeQueue() + await q.removeJobScheduler('rec-1') + + expect(mockBullQueue.removeJobScheduler).toHaveBeenCalledWith('schedule:rec-1') + }) + + it('does not throw when the underlying call fails (swallows error)', async () => { + mockBullQueue.removeJobScheduler.mockRejectedValue(new Error('redis fail')) + const q = makeQueue() + await expect(q.removeJobScheduler('rec-1')).resolves.toBeUndefined() + }) + + it('logs a warning when removeJobScheduler fails', async () => { + const logger = require('../utils/logger').default + mockBullQueue.removeJobScheduler.mockRejectedValue(new Error('redis fail')) + const q = makeQueue() + await q.removeJobScheduler('rec-1') + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('rec-1')) + }) +}) diff --git a/packages/server/src/services/chatflows/index.test.ts b/packages/server/src/services/chatflows/index.test.ts new file mode 100644 index 00000000000..b0e7e81ef9d --- /dev/null +++ b/packages/server/src/services/chatflows/index.test.ts @@ -0,0 +1,535 @@ +/** + * Unit tests for chatflowsService.saveChatflow and chatflowsService.updateChatflow. + * All infrastructure (TypeORM, ScheduleService, ScheduleBeat, telemetry, etc.) + * is mocked β€” no DB or Express app required. + */ + +// ─── Shared repo mock ───────────────────────────────────────────────────────── + +const mockRepo = { + findOneBy: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + merge: jest.fn(), + countBy: jest.fn(), + createQueryBuilder: jest.fn() +} + +const mockAppServer = { + AppDataSource: { + getRepository: jest.fn().mockReturnValue(mockRepo) + }, + telemetry: { + sendTelemetry: jest.fn().mockResolvedValue(undefined) + }, + identityManager: { + getProductIdFromSubscription: jest.fn().mockResolvedValue('prod-1') + }, + metricsProvider: { + incrementCounter: jest.fn() + }, + usageCacheManager: {} +} + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../../utils/getRunningExpressApp', () => ({ + getRunningExpressApp: jest.fn().mockReturnValue(mockAppServer) +})) +jest.mock('../../database/entities/ChatFlow', () => ({ + ChatFlow: class ChatFlow {}, + EnumChatflowType: { AGENTFLOW: 'AGENTFLOW', CHATFLOW: 'CHATFLOW', MULTIAGENT: 'MULTIAGENT' } +})) +jest.mock('../../database/entities/ChatMessage', () => ({ ChatMessage: class ChatMessage {} })) +jest.mock('../../database/entities/ChatMessageFeedback', () => ({ ChatMessageFeedback: class ChatMessageFeedback {} })) +jest.mock('../../database/entities/UpsertHistory', () => ({ UpsertHistory: class UpsertHistory {} })) +jest.mock('../../database/entities/ScheduleRecord', () => ({ + ScheduleRecord: class ScheduleRecord {}, + ScheduleTriggerType: { AGENTFLOW: 'AGENTFLOW' } +})) +jest.mock('../../enterprise/database/entities/workspace.entity', () => ({ Workspace: class Workspace {} })) +jest.mock('../../enterprise/utils/ControllerServiceUtils', () => ({ getWorkspaceSearchOptions: jest.fn().mockReturnValue({}) })) +jest.mock('../../errors/internalFlowiseError', () => ({ + InternalFlowiseError: class InternalFlowiseError extends Error { + constructor(public statusCode: number, message: string) { + super(message) + this.name = 'InternalFlowiseError' + } + } +})) +jest.mock('../../errors/utils', () => ({ getErrorMessage: (e: unknown) => String(e) })) +jest.mock('../../services/documentstore', () => ({ + __esModule: true, + default: { updateDocumentStoreUsage: jest.fn().mockResolvedValue(undefined) } +})) +jest.mock('../../utils', () => ({ + constructGraphs: jest.fn().mockReturnValue({ graph: {}, nodeDependencies: {} }), + getAppVersion: jest.fn().mockResolvedValue('1.0.0'), + getEndingNodes: jest.fn().mockReturnValue([]), + getTelemetryFlowObj: jest.fn().mockReturnValue({}), + isFlowValidForStream: jest.fn().mockReturnValue(false) +})) +jest.mock('../../utils/fileValidation', () => ({ + sanitizeAllowedUploadMimeTypesFromConfig: jest.fn((x: string) => x) +})) +jest.mock('../../utils/fileRepository', () => ({ + containsBase64File: jest.fn().mockReturnValue(false), + updateFlowDataWithFilePaths: jest.fn().mockImplementation(async (_id: string, fd: string) => fd) +})) +jest.mock('../../utils/sanitizeFlowData', () => ({ + sanitizeFlowDataForPublicEndpoint: jest.fn().mockReturnValue('{}') +})) +jest.mock('../../utils/getUploadsConfig', () => ({ utilGetUploadsConfig: jest.fn().mockResolvedValue(null) })) +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() } +})) +jest.mock('../../utils/quotaUsage', () => ({ updateStorageUsage: jest.fn().mockResolvedValue(undefined) })) +jest.mock('../../services/schedule', () => ({ + __esModule: true, + default: { + resolveScheduleCron: jest.fn().mockReturnValue({ valid: true, cronExpression: '* * * * *' }), + canScheduleEnable: jest.fn().mockReturnValue(true), + createOrUpdateSchedule: jest.fn().mockResolvedValue({ id: 'sched-1', enabled: true }), + deleteScheduleForTarget: jest.fn().mockResolvedValue(undefined) + } +})) +jest.mock('../../queue/ScheduleBeat', () => ({ + ScheduleBeat: { + getInstance: jest.fn().mockReturnValue({ + onScheduleChanged: jest.fn().mockResolvedValue(undefined) + }) + } +})) +jest.mock('flowise-components', () => ({ removeFolderFromStorage: jest.fn().mockResolvedValue({ totalSize: 0 }) }), { virtual: true }) +jest.mock('uuid', () => ({ validate: jest.fn().mockReturnValue(true) })) +jest.mock('http-status-codes', () => ({ + StatusCodes: { OK: 200, BAD_REQUEST: 400, NOT_FOUND: 404, INTERNAL_SERVER_ERROR: 500 } +})) + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import chatflowsService from './index' +import scheduleService from '../../services/schedule' +import { ScheduleBeat } from '../../queue/ScheduleBeat' +import { containsBase64File, updateFlowDataWithFilePaths } from '../../utils/fileRepository' +import { EnumChatflowType } from '../../database/entities/ChatFlow' +import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' + +const mockContainsBase64File = containsBase64File as jest.Mock +const mockUpdateFlowDataWithFilePaths = updateFlowDataWithFilePaths as jest.Mock +const mockOnScheduleChanged = ScheduleBeat.getInstance().onScheduleChanged as jest.Mock +const mockCreateOrUpdateSchedule = scheduleService.createOrUpdateSchedule as jest.Mock +const mockDeleteScheduleForTarget = scheduleService.deleteScheduleForTarget as jest.Mock +const mockResolveScheduleCron = scheduleService.resolveScheduleCron as jest.Mock +const mockCanScheduleEnable = scheduleService.canScheduleEnable as jest.Mock + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Build a minimal scheduleInput AGENTFLOW flowData JSON */ +const makeScheduleFlowData = (inputs: Record = {}) => + JSON.stringify({ + nodes: [ + { + id: 'start-0', + data: { + name: 'startAgentflow', + inputs: { + startInputType: 'scheduleInput', + scheduleCronExpression: '* * * * *', + scheduleTimezone: 'UTC', + scheduleDefaultInput: 'hello', + ...inputs + } + } + } + ], + edges: [] + }) + +/** Build a non-schedule AGENTFLOW flowData JSON (chatInput start) */ +const makeChatInputFlowData = () => + JSON.stringify({ + nodes: [{ id: 'start-0', data: { name: 'startAgentflow', inputs: { startInputType: 'chatInput' } } }], + edges: [] + }) + +/** Build a plain (non-agentflow) flowData JSON */ +const makePlainFlowData = () => JSON.stringify({ nodes: [], edges: [] }) + +const makeChatflow = (overrides: Record = {}) => ({ + id: 'flow-1', + type: EnumChatflowType.AGENTFLOW, + flowData: makeScheduleFlowData(), + workspaceId: 'ws-1', + chatbotConfig: undefined, + ...overrides +}) + +const SAVE_ARGS = { + orgId: 'org-1', + workspaceId: 'ws-1', + subscriptionId: 'sub-1', + usageCacheManager: {} as any +} + +beforeEach(() => { + jest.clearAllMocks() + mockAppServer.AppDataSource.getRepository.mockReturnValue(mockRepo) + mockRepo.create.mockImplementation((x: unknown) => x) + mockRepo.save.mockResolvedValue(makeChatflow()) + mockRepo.merge.mockImplementation((_existing: any, updates: any) => ({ ...makeChatflow(), ...updates })) + mockContainsBase64File.mockReturnValue(false) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: true }) + mockDeleteScheduleForTarget.mockResolvedValue(undefined) + mockResolveScheduleCron.mockReturnValue({ valid: true, cronExpression: '* * * * *' }) + mockCanScheduleEnable.mockReturnValue(true) + ;(ScheduleBeat.getInstance as jest.Mock).mockReturnValue({ + onScheduleChanged: jest.fn().mockResolvedValue(undefined) + }) +}) + +// ─── saveChatflow ───────────────────────────────────────────────────────────── + +describe('saveChatflow', () => { + it('saves and returns the chatflow', async () => { + const newFlow = makeChatflow({ type: EnumChatflowType.AGENTFLOW }) + const saved = makeChatflow() + mockRepo.save.mockResolvedValue(saved) + + const result = await chatflowsService.saveChatflow( + newFlow as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockRepo.save).toHaveBeenCalled() + expect(result).toBe(saved) + }) + + it('throws BAD_REQUEST for an invalid chatflow type', async () => { + const badFlow = makeChatflow({ type: 'INVALID_TYPE' }) + + await expect( + chatflowsService.saveChatflow( + badFlow as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + ).rejects.toMatchObject({ statusCode: 400 }) + }) + + // ── schedule sync (AGENTFLOW + scheduleInput) ──────────────────────────── + + it('creates or updates the schedule when the start node is scheduleInput', async () => { + const newFlow = makeChatflow() + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeScheduleFlowData() })) + + await chatflowsService.saveChatflow( + newFlow as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith( + expect.objectContaining({ + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: 'flow-1', + workspaceId: 'ws-1' + }) + ) + }) + + it('calls onScheduleChanged upsert when the schedule is enabled', async () => { + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeScheduleFlowData() })) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: true }) + mockCanScheduleEnable.mockReturnValue(true) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + const beat = ScheduleBeat.getInstance() + expect(beat.onScheduleChanged).toHaveBeenCalledWith('sched-1', 'upsert') + }) + + it('does NOT call onScheduleChanged when the schedule is disabled', async () => { + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeScheduleFlowData() })) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: false }) + mockCanScheduleEnable.mockReturnValue(false) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + const beat = ScheduleBeat.getInstance() + expect(beat.onScheduleChanged).not.toHaveBeenCalled() + }) + + it('passes scheduleEndDate as a Date when set in flowData', async () => { + const futureDate = new Date(Date.now() + 86_400_000).toISOString() + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeScheduleFlowData({ scheduleEndDate: futureDate }) })) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith(expect.objectContaining({ endDate: expect.any(Date) })) + }) + + it('passes undefined endDate when scheduleEndDate is not set', async () => { + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeScheduleFlowData() })) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith(expect.objectContaining({ endDate: undefined })) + }) + + it('does not create a schedule when the start node type is chatInput', async () => { + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeChatInputFlowData() })) + + await chatflowsService.saveChatflow( + makeChatflow({ flowData: makeChatInputFlowData() }) as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).not.toHaveBeenCalled() + }) + + it('does not create a schedule for a non-AGENTFLOW type', async () => { + const chatflow = makeChatflow({ type: EnumChatflowType.CHATFLOW, flowData: makePlainFlowData() }) + mockRepo.save.mockResolvedValue(chatflow) + + await chatflowsService.saveChatflow( + chatflow as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).not.toHaveBeenCalled() + }) + + // ── telemetry ───────────────────────────────────────────────────────────── + + it('sends chatflow_created telemetry after saving', async () => { + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makePlainFlowData() })) + + await chatflowsService.saveChatflow( + makeChatflow({ type: EnumChatflowType.CHATFLOW, flowData: makePlainFlowData() }) as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockAppServer.telemetry.sendTelemetry).toHaveBeenCalledWith('chatflow_created', expect.any(Object), SAVE_ARGS.orgId) + }) +}) + +// ─── updateChatflow ─────────────────────────────────────────────────────────── + +describe('updateChatflow', () => { + const existingFlow = makeChatflow() + + it('saves and returns the merged chatflow', async () => { + const updates = makeChatflow({ flowData: makeScheduleFlowData() }) + const merged = { ...existingFlow, ...updates } + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + + const result = await chatflowsService.updateChatflow(existingFlow as any, updates as any, 'org-1', 'ws-1', 'sub-1') + + expect(mockRepo.merge).toHaveBeenCalled() + expect(mockRepo.save).toHaveBeenCalled() + expect(result).toBe(merged) + }) + + it('throws BAD_REQUEST when updateChatFlow.type is invalid', async () => { + const updates = makeChatflow({ type: 'BAD_TYPE' }) + + await expect(chatflowsService.updateChatflow(existingFlow as any, updates as any, 'org-1', 'ws-1', 'sub-1')).rejects.toMatchObject({ + statusCode: 400 + }) + }) + + it('preserves existing type when updateChatFlow.type is not provided', async () => { + const updates = { flowData: makeScheduleFlowData() } // no type field + const merged = { ...existingFlow, flowData: makeScheduleFlowData() } + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + + await chatflowsService.updateChatflow(existingFlow as any, updates as any, 'org-1', 'ws-1', 'sub-1') + + // Type should have been copied from existing flow + expect(updates).toMatchObject({ type: existingFlow.type }) + }) + + it('throws BAD_REQUEST when chatbotConfig is invalid JSON', async () => { + const updates = makeChatflow({ chatbotConfig: 'not-json' }) + + await expect(chatflowsService.updateChatflow(existingFlow as any, updates as any, 'org-1', 'ws-1', 'sub-1')).rejects.toMatchObject({ + statusCode: 400 + }) + }) + + // ── schedule sync β€” scheduleInput branch ───────────────────────────────── + + it('creates or updates the schedule when start node is scheduleInput', async () => { + const updates = makeChatflow({ flowData: makeScheduleFlowData() }) + const merged = { ...existingFlow, flowData: makeScheduleFlowData(), type: EnumChatflowType.AGENTFLOW } + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + + await chatflowsService.updateChatflow(existingFlow as any, updates as any, 'org-1', 'ws-1', 'sub-1') + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith( + expect.objectContaining({ triggerType: ScheduleTriggerType.AGENTFLOW, targetId: 'flow-1', workspaceId: 'ws-1' }) + ) + }) + + it('calls onScheduleChanged upsert when the updated schedule is enabled', async () => { + const merged = makeChatflow({ flowData: makeScheduleFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: true }) + + await chatflowsService.updateChatflow(existingFlow as any, makeChatflow() as any, 'org-1', 'ws-1', 'sub-1') + + const beat = ScheduleBeat.getInstance() + expect(beat.onScheduleChanged).toHaveBeenCalledWith('sched-1', 'upsert') + }) + + it('calls onScheduleChanged delete when the updated schedule is disabled', async () => { + const merged = makeChatflow({ flowData: makeScheduleFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: false }) + mockCanScheduleEnable.mockReturnValue(false) + + await chatflowsService.updateChatflow(existingFlow as any, makeChatflow() as any, 'org-1', 'ws-1', 'sub-1') + + const beat = ScheduleBeat.getInstance() + expect(beat.onScheduleChanged).toHaveBeenCalledWith('sched-1', 'delete') + }) + + it('sets enabled=false in createOrUpdateSchedule when canScheduleEnable returns false', async () => { + const merged = makeChatflow({ flowData: makeScheduleFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + mockCanScheduleEnable.mockReturnValue(false) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: false }) + + await chatflowsService.updateChatflow(existingFlow as any, makeChatflow() as any, 'org-1', 'ws-1', 'sub-1') + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('passes undefined enabled in createOrUpdateSchedule when canScheduleEnable returns true (preserve existing)', async () => { + const merged = makeChatflow({ flowData: makeScheduleFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + mockCanScheduleEnable.mockReturnValue(true) + mockCreateOrUpdateSchedule.mockResolvedValue({ id: 'sched-1', enabled: true }) + + await chatflowsService.updateChatflow(existingFlow as any, makeChatflow() as any, 'org-1', 'ws-1', 'sub-1') + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith(expect.objectContaining({ enabled: undefined })) + }) + + // ── schedule sync β€” non-scheduleInput branch ────────────────────────────── + + it('deletes existing schedule when start node switches away from scheduleInput', async () => { + const merged = makeChatflow({ flowData: makeChatInputFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + + await chatflowsService.updateChatflow( + existingFlow as any, + makeChatflow({ flowData: makeChatInputFlowData() }) as any, + 'org-1', + 'ws-1', + 'sub-1' + ) + + expect(mockDeleteScheduleForTarget).toHaveBeenCalledWith('flow-1', ScheduleTriggerType.AGENTFLOW, 'ws-1') + }) + + it('calls onScheduleChanged delete after deleting the existing schedule record', async () => { + const merged = makeChatflow({ flowData: makeChatInputFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + mockDeleteScheduleForTarget.mockResolvedValue({ id: 'sched-old' }) + + await chatflowsService.updateChatflow( + existingFlow as any, + makeChatflow({ flowData: makeChatInputFlowData() }) as any, + 'org-1', + 'ws-1', + 'sub-1' + ) + + const beat = ScheduleBeat.getInstance() + expect(beat.onScheduleChanged).toHaveBeenCalledWith('sched-old', 'delete') + }) + + it('does not call onScheduleChanged when no existing schedule was found', async () => { + const merged = makeChatflow({ flowData: makeChatInputFlowData() }) + mockRepo.merge.mockReturnValue(merged) + mockRepo.save.mockResolvedValue(merged) + mockDeleteScheduleForTarget.mockResolvedValue(undefined) + + await chatflowsService.updateChatflow( + existingFlow as any, + makeChatflow({ flowData: makeChatInputFlowData() }) as any, + 'org-1', + 'ws-1', + 'sub-1' + ) + + const beat = ScheduleBeat.getInstance() + expect(beat.onScheduleChanged).not.toHaveBeenCalled() + }) + + it('does not touch schedules for a non-AGENTFLOW type', async () => { + const nonAgentFlow = makeChatflow({ type: EnumChatflowType.CHATFLOW, flowData: makePlainFlowData() }) + mockRepo.merge.mockReturnValue(nonAgentFlow) + mockRepo.save.mockResolvedValue(nonAgentFlow) + + await chatflowsService.updateChatflow(existingFlow as any, nonAgentFlow as any, 'org-1', 'ws-1', 'sub-1') + + expect(mockCreateOrUpdateSchedule).not.toHaveBeenCalled() + expect(mockDeleteScheduleForTarget).not.toHaveBeenCalled() + }) +}) diff --git a/packages/server/src/services/schedule/index.test.ts b/packages/server/src/services/schedule/index.test.ts new file mode 100644 index 00000000000..d5f75ed27d2 --- /dev/null +++ b/packages/server/src/services/schedule/index.test.ts @@ -0,0 +1,504 @@ +/** + * Unit tests for schedule service (index.ts) β€” server-side / DB logic. + * All TypeORM repositories and external dependencies are mocked so no real + * database or Express app is required. + */ + +// ─── Infrastructure mocks ───────────────────────────────────────────────────── + +const mockRepo = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + update: jest.fn() +} + +const mockAppDataSource = { + getRepository: jest.fn().mockReturnValue(mockRepo) +} + +const mockAppServer = { + AppDataSource: mockAppDataSource +} + +jest.mock('../../database/entities/ScheduleRecord', () => ({ + ScheduleRecord: class ScheduleRecord {}, + ScheduleTriggerType: { AGENTFLOW: 'AGENTFLOW' } +})) +jest.mock('../../database/entities/ScheduleTriggerLog', () => ({ + ScheduleTriggerLog: class ScheduleTriggerLog {}, + ScheduleTriggerStatus: { + QUEUED: 'QUEUED', + RUNNING: 'RUNNING', + SUCCEEDED: 'SUCCEEDED', + FAILED: 'FAILED', + SKIPPED: 'SKIPPED' + } +})) +jest.mock('../../database/entities/ChatFlow', () => ({ ChatFlow: class ChatFlow {} })) +jest.mock('../../errors/internalFlowiseError', () => ({ + InternalFlowiseError: class InternalFlowiseError extends Error { + constructor(public statusCode: number, message: string) { + super(message) + this.name = 'InternalFlowiseError' + } + } +})) +jest.mock('../../errors/utils', () => ({ getErrorMessage: (e: unknown) => String(e) })) +jest.mock('../../utils/getRunningExpressApp', () => ({ getRunningExpressApp: jest.fn().mockReturnValue(mockAppServer) })) +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), error: jest.fn(), info: jest.fn() } +})) + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' +import { ScheduleTriggerStatus } from '../../database/entities/ScheduleTriggerLog' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import scheduleService from './index' + +// Expose the typed mock for convenience +const mockGetApp = getRunningExpressApp as jest.Mock + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Build a minimal ScheduleRecord-like object for tests */ +const makeRecord = (overrides: Record = {}) => ({ + id: 'rec-1', + targetId: 'flow-1', + triggerType: ScheduleTriggerType.AGENTFLOW, + cronExpression: '* * * * *', + timezone: 'UTC', + enabled: true, + workspaceId: 'ws-1', + defaultInput: 'hello', + nodeId: undefined, + endDate: undefined, + nextRunAt: undefined, + ...overrides +}) + +/** Build flowData JSON with a scheduleInput Start node */ +const makeScheduleFlowData = (inputs: Record = {}) => + JSON.stringify({ + nodes: [ + { + id: 'start-0', + data: { + name: 'startAgentflow', + inputs: { + startInputType: 'scheduleInput', + scheduleCronExpression: '* * * * *', + scheduleDefaultInput: 'hello', + ...inputs + } + } + } + ] + }) + +beforeEach(() => { + jest.clearAllMocks() + mockGetApp.mockReturnValue(mockAppServer) + mockAppDataSource.getRepository.mockReturnValue(mockRepo) +}) + +// ─── createOrUpdateSchedule ─────────────────────────────────────────────────── + +describe('createOrUpdateSchedule', () => { + const baseInput = { + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: 'flow-1', + cronExpression: '0 9 * * 1-5', + timezone: 'UTC', + workspaceId: 'ws-1', + defaultInput: 'Run daily job' + } + + it('creates a new record when none exists', async () => { + const saved = makeRecord() + mockRepo.findOne.mockResolvedValue(null) + mockRepo.create.mockReturnValue(saved) + mockRepo.save.mockResolvedValue(saved) + + const result = await scheduleService.createOrUpdateSchedule(baseInput) + + expect(mockRepo.findOne).toHaveBeenCalledWith({ + where: { targetId: 'flow-1', triggerType: ScheduleTriggerType.AGENTFLOW, workspaceId: 'ws-1' } + }) + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + cronExpression: '0 9 * * 1-5', + timezone: 'UTC', + targetId: 'flow-1', + workspaceId: 'ws-1', + enabled: true // valid cron β†’ default enabled + }) + ) + expect(mockRepo.save).toHaveBeenCalledWith(saved) + expect(result).toBe(saved) + }) + + it('updates an existing record when one exists', async () => { + const existing = makeRecord() + const saved = { ...existing, cronExpression: '0 9 * * 1-5' } + mockRepo.findOne.mockResolvedValue(existing) + mockRepo.save.mockResolvedValue(saved) + + const result = await scheduleService.createOrUpdateSchedule(baseInput) + + expect(mockRepo.create).not.toHaveBeenCalled() + expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({ cronExpression: '0 9 * * 1-5' })) + expect(result).toBe(saved) + }) + + it('falls back to FALLBACK_CRON_EXPRESSION when cron is invalid', async () => { + mockRepo.findOne.mockResolvedValue(null) + const saved = makeRecord({ cronExpression: '0 0 * * *' }) + mockRepo.create.mockReturnValue(saved) + mockRepo.save.mockResolvedValue(saved) + + await scheduleService.createOrUpdateSchedule({ ...baseInput, cronExpression: 'not-valid' }) + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + cronExpression: '0 0 * * *', // fallback + enabled: false // invalid cron β†’ default disabled + }) + ) + }) + + it('respects explicit enabled=false even for a valid cron', async () => { + mockRepo.findOne.mockResolvedValue(null) + const saved = makeRecord({ enabled: false }) + mockRepo.create.mockReturnValue(saved) + mockRepo.save.mockResolvedValue(saved) + + await scheduleService.createOrUpdateSchedule({ ...baseInput, enabled: false }) + + expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('re-throws InternalFlowiseError from the repo', async () => { + const err = new InternalFlowiseError(500, 'db error') + mockRepo.findOne.mockRejectedValue(err) + + await expect(scheduleService.createOrUpdateSchedule(baseInput)).rejects.toThrow('db error') + }) + + it('wraps unexpected errors in InternalFlowiseError', async () => { + mockRepo.findOne.mockRejectedValue(new Error('unexpected')) + + await expect(scheduleService.createOrUpdateSchedule(baseInput)).rejects.toMatchObject({ + statusCode: 500 + }) + }) +}) + +// ─── deleteScheduleForTarget ────────────────────────────────────────────────── + +describe('deleteScheduleForTarget', () => { + it('deletes the record and returns it when found', async () => { + const record = makeRecord() + mockRepo.findOne.mockResolvedValue(record) + mockRepo.delete.mockResolvedValue(undefined) + + const result = await scheduleService.deleteScheduleForTarget('flow-1', ScheduleTriggerType.AGENTFLOW, 'ws-1') + + expect(mockRepo.delete).toHaveBeenCalledWith('rec-1') + expect(result).toBe(record) + }) + + it('returns undefined without deleting when no record exists', async () => { + mockRepo.findOne.mockResolvedValue(null) + + const result = await scheduleService.deleteScheduleForTarget('flow-1', ScheduleTriggerType.AGENTFLOW, 'ws-1') + + expect(mockRepo.delete).not.toHaveBeenCalled() + expect(result).toBeUndefined() + }) + + it('throws InternalFlowiseError on repo failure', async () => { + mockRepo.findOne.mockRejectedValue(new Error('db fail')) + + await expect(scheduleService.deleteScheduleForTarget('flow-1', ScheduleTriggerType.AGENTFLOW, 'ws-1')).rejects.toMatchObject({ + statusCode: 500 + }) + }) +}) + +// ─── getEnabledSchedulesBatch ───────────────────────────────────────────────── + +describe('getEnabledSchedulesBatch', () => { + it('queries only enabled records with correct defaults', async () => { + const records = [makeRecord(), makeRecord({ id: 'rec-2' })] + mockRepo.find.mockResolvedValue(records) + + const result = await scheduleService.getEnabledSchedulesBatch() + + expect(mockRepo.find).toHaveBeenCalledWith({ + where: { enabled: true }, + order: { createdDate: 'ASC' }, + skip: 0, + take: 100 + }) + expect(result).toBe(records) + }) + + it('forwards custom skip/take values', async () => { + mockRepo.find.mockResolvedValue([]) + + await scheduleService.getEnabledSchedulesBatch(50, 25) + + expect(mockRepo.find).toHaveBeenCalledWith(expect.objectContaining({ skip: 50, take: 25 })) + }) + + it('throws InternalFlowiseError on failure', async () => { + mockRepo.find.mockRejectedValue(new Error('db fail')) + + await expect(scheduleService.getEnabledSchedulesBatch()).rejects.toMatchObject({ statusCode: 500 }) + }) +}) + +// ─── updateScheduleAfterRun ─────────────────────────────────────────────────── + +describe('updateScheduleAfterRun', () => { + it('updates lastRunAt and nextRunAt on the record', async () => { + mockRepo.update.mockResolvedValue(undefined) + + await scheduleService.updateScheduleAfterRun(mockAppDataSource as any, 'rec-1', '* * * * *', 'UTC') + + expect(mockRepo.update).toHaveBeenCalledWith( + { id: 'rec-1' }, + expect.objectContaining({ + lastRunAt: expect.any(Date), + nextRunAt: expect.any(Date) + }) + ) + }) + + it('does not throw on update failure (logs instead)', async () => { + mockRepo.update.mockRejectedValue(new Error('db fail')) + + // Should resolve without throwing + await expect(scheduleService.updateScheduleAfterRun(mockAppDataSource as any, 'rec-1', '* * * * *')).resolves.toBeUndefined() + }) +}) + +// ─── getScheduleStatus ──────────────────────────────────────────────────────── + +describe('getScheduleStatus', () => { + it('returns canEnable=false when chatflow is missing', async () => { + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce(null) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toMatch(/Flow not found/) + }) + + it('returns canEnable=false when chatflow has no flowData', async () => { + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce({ id: 'flow-1' }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toMatch(/Flow not found/) + }) + + it('returns canEnable=false when Start node is not a scheduleInput type', async () => { + const flowData = JSON.stringify({ + nodes: [{ data: { name: 'startAgentflow', inputs: { startInputType: 'humanInput' } } }] + }) + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce({ id: 'flow-1', flowData }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toMatch(/not configured as a scheduled flow/) + }) + + it('returns canEnable=false when cron expression is invalid', async () => { + const flowData = makeScheduleFlowData({ scheduleCronExpression: 'not-valid' }) + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce({ id: 'flow-1', flowData }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toBeDefined() + }) + + it('returns canEnable=false when end date is in the past', async () => { + const pastDate = new Date(Date.now() - 60_000).toISOString() + const flowData = makeScheduleFlowData({ scheduleEndDate: pastDate }) + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce({ id: 'flow-1', flowData }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toMatch(/End date is in the past/) + }) + + it('returns canEnable=false when defaultInput is missing', async () => { + const flowData = makeScheduleFlowData({ scheduleDefaultInput: '' }) + mockRepo.findOne.mockResolvedValueOnce(makeRecord({ defaultInput: undefined })).mockResolvedValueOnce({ id: 'flow-1', flowData }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toMatch(/Default input is required/) + }) + + it('returns canEnable=true for a fully valid schedule', async () => { + const flowData = makeScheduleFlowData() + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce({ id: 'flow-1', flowData }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(true) + expect(result.record).toBeDefined() + }) + + it('returns canEnable=false and reason when flowData JSON is malformed', async () => { + mockRepo.findOne.mockResolvedValueOnce(makeRecord()).mockResolvedValueOnce({ id: 'flow-1', flowData: '{invalid json' }) + + const result = await scheduleService.getScheduleStatus('flow-1', 'ws-1') + + expect(result.canEnable).toBe(false) + expect(result.reason).toMatch(/Could not parse/) + }) + + it('throws InternalFlowiseError on unexpected DB error', async () => { + mockRepo.findOne.mockRejectedValue(new Error('db fail')) + + await expect(scheduleService.getScheduleStatus('flow-1', 'ws-1')).rejects.toMatchObject({ statusCode: 500 }) + }) +}) + +// ─── toggleScheduleEnabled ──────────────────────────────────────────────────── + +describe('toggleScheduleEnabled', () => { + it('disables an existing schedule without checking validity', async () => { + const record = makeRecord({ enabled: true }) + const saved = { ...record, enabled: false } + mockRepo.findOne.mockResolvedValue(record) + mockRepo.save.mockResolvedValue(saved) + + const result = await scheduleService.toggleScheduleEnabled('flow-1', 'ws-1', false) + + expect(result.enabled).toBe(false) + expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + }) + + it('enables a valid schedule successfully', async () => { + const record = makeRecord({ enabled: false }) + const saved = { ...record, enabled: true } + + // First findOne β†’ schedule record; second findOne (inside getScheduleStatus) β†’ schedule record again; + // third findOne (inside getScheduleStatus) β†’ chatflow + const flowData = makeScheduleFlowData() + mockRepo.findOne + .mockResolvedValueOnce(record) // toggleScheduleEnabled lookup + .mockResolvedValueOnce(record) // getScheduleStatus schedule record lookup + .mockResolvedValueOnce({ id: 'flow-1', flowData }) // getScheduleStatus chatflow lookup + mockRepo.save.mockResolvedValue(saved) + + const result = await scheduleService.toggleScheduleEnabled('flow-1', 'ws-1', true) + + expect(result.enabled).toBe(true) + }) + + it('throws NOT_FOUND when no schedule record exists', async () => { + mockRepo.findOne.mockResolvedValue(null) + + await expect(scheduleService.toggleScheduleEnabled('flow-1', 'ws-1', true)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('throws BAD_REQUEST when enabling an invalid schedule', async () => { + const record = makeRecord({ enabled: false }) + // getScheduleStatus will return canEnable=false (no chatflow) + mockRepo.findOne + .mockResolvedValueOnce(record) // toggle lookup + .mockResolvedValueOnce(record) // getScheduleStatus schedule lookup + .mockResolvedValueOnce(null) // getScheduleStatus chatflow β†’ missing + + await expect(scheduleService.toggleScheduleEnabled('flow-1', 'ws-1', true)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('throws InternalFlowiseError on unexpected repo error', async () => { + mockRepo.findOne.mockRejectedValue(new Error('db fail')) + + await expect(scheduleService.toggleScheduleEnabled('flow-1', 'ws-1', false)).rejects.toMatchObject({ statusCode: 500 }) + }) +}) + +// ─── createTriggerLog ───────────────────────────────────────────────────────── + +describe('createTriggerLog', () => { + const logData = { + appDataSource: mockAppDataSource as any, + scheduleRecordId: 'rec-1', + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: 'flow-1', + status: ScheduleTriggerStatus.RUNNING, + scheduledAt: new Date('2025-01-01T09:00:00Z'), + workspaceId: 'ws-1' + } + + it('creates and saves a log entry with a generated id', async () => { + const saved = { id: 'log-uuid', ...logData } + mockRepo.create.mockReturnValue(saved) + mockRepo.save.mockResolvedValue(saved) + + const result = await scheduleService.createTriggerLog(logData) + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + scheduleRecordId: 'rec-1', + status: ScheduleTriggerStatus.RUNNING, + targetId: 'flow-1' + }) + ) + expect(mockRepo.save).toHaveBeenCalledWith(saved) + expect(result).toBe(saved) + }) + + it('re-throws errors from the repo', async () => { + const err = new Error('insert failed') + mockRepo.create.mockReturnValue({}) + mockRepo.save.mockRejectedValue(err) + + await expect(scheduleService.createTriggerLog(logData)).rejects.toThrow('insert failed') + }) +}) + +// ─── updateTriggerLog ───────────────────────────────────────────────────────── + +describe('updateTriggerLog', () => { + it('calls update with the correct id and fields', async () => { + mockRepo.update.mockResolvedValue(undefined) + + await scheduleService.updateTriggerLog(mockAppDataSource as any, 'log-1', { + status: ScheduleTriggerStatus.SUCCEEDED, + elapsedTimeMs: 1234, + executionId: 'exec-1' + }) + + expect(mockRepo.update).toHaveBeenCalledWith( + { id: 'log-1' }, + { status: ScheduleTriggerStatus.SUCCEEDED, elapsedTimeMs: 1234, executionId: 'exec-1' } + ) + }) + + it('does not throw on update failure (logs instead)', async () => { + mockRepo.update.mockRejectedValue(new Error('db fail')) + + await expect( + scheduleService.updateTriggerLog(mockAppDataSource as any, 'log-1', { status: ScheduleTriggerStatus.FAILED }) + ).resolves.toBeUndefined() + }) +}) diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts index a3e14d44b58..4e23346622e 100644 --- a/packages/server/src/services/schedule/index.ts +++ b/packages/server/src/services/schedule/index.ts @@ -8,6 +8,26 @@ import { getErrorMessage } from '../../errors/utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import logger from '../../utils/logger' import { DataSource } from 'typeorm' +import { + validateCronExpression, + computeNextRunAt, + isDefaultInputValid, + resolveScheduleCron, + validateVisualPickerFields, + buildCronFromVisualPicker, + canScheduleEnable +} from './utils' + +export { + validateCronExpression, + computeNextRunAt, + validateVisualPickerFields, + buildCronFromVisualPicker, + resolveScheduleCron, + isDefaultInputValid, + canScheduleEnable +} from './utils' +export type { VisualPickerInput } from './utils' export interface CreateScheduleInput { triggerType: ScheduleTriggerType @@ -35,98 +55,12 @@ export interface UpdateScheduleInput { * to fix the cron expression without losing the schedule record. * The beat will skip execution if it detects this fallback expression, and will log an error for visibility. */ -const FALLBACK_CRON_EXPRESSION = '0 0 * * *' // daily at midnight UTC -const FALLBACK_TIMEZONE = 'UTC' +export const FALLBACK_CRON_EXPRESSION = '0 0 * * *' // daily at midnight UTC +export const FALLBACK_TIMEZONE = 'UTC' /* Schedule batch size for processing schedules in batches */ const SCHEDULE_BATCH_SIZE = 100 -/** - * Validates a cron expression and returns parsed info. - * Uses a lightweight regex-based check without external dependencies. - * - * Supports standard 5-field cron: minute hour day month weekday - */ -export const validateCronExpression = (expression: string, timezone: string = 'UTC'): { valid: boolean; error?: string } => { - if (!expression || typeof expression !== 'string') { - return { valid: false, error: 'Cron expression must be a non-empty string' } - } - - const trimmed = expression.trim() - const fields = trimmed.split(/\s+/) - - if (fields.length !== 5 && fields.length !== 6) { - return { - valid: false, - error: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)' - } - } - - // Validate timezone - try { - Intl.DateTimeFormat('en-US', { timeZone: timezone }) - } catch { - return { valid: false, error: `Invalid timezone: ${timezone}` } - } - - // Returns true if s is a valid integer in [min, max] or a valid range "start-end" - const isValidRangeOrNumber = (s: string, min: number, max: number): boolean => { - const dashIdx = s.indexOf('-') - if (dashIdx !== -1) { - const startStr = s.slice(0, dashIdx) - const endStr = s.slice(dashIdx + 1) - if (!/^\d+$/.test(startStr) || !/^\d+$/.test(endStr)) return false - const start = parseInt(startStr, 10) - const end = parseInt(endStr, 10) - return start >= min && start <= max && end >= min && end <= max && start <= end - } - if (!/^\d+$/.test(s)) return false - const n = parseInt(s, 10) - return n >= min && n <= max - } - - // Validate a single cron field: supports *, numbers, ranges (n-m), steps (*/s, n/s, n-m/s), and comma-separated lists - const validateCronField = (field: string, min: number, max: number): boolean => { - const parts = field.split(',') - if (parts.some((p) => p === '')) return false // catches leading/trailing/consecutive commas - - for (const part of parts) { - const slashIdx = part.indexOf('/') - if (slashIdx !== -1) { - const base = part.slice(0, slashIdx) - const stepStr = part.slice(slashIdx + 1) - if (!/^\d+$/.test(stepStr)) return false - const step = parseInt(stepStr, 10) - if (step < 1) return false - // Base must be *, a plain number, or a range - if (base !== '*' && !isValidRangeOrNumber(base, min, max)) return false - } else if (part !== '*') { - if (!isValidRangeOrNumber(part, min, max)) return false - } - } - return true - } - - // Per-position field ranges [min, max]: minute hour day-of-month month day-of-week - const fieldRanges: Array<[number, number]> = [ - [0, 59], // minutes (or seconds when 6-field) - [0, 23], // hours - [1, 31], // day of month - [1, 12], // month - [0, 7] // day of week (0 and 7 both represent Sunday) - ] - - // For 6-field cron, prepend an extra seconds range (same as minutes: 0-59) - const ranges: Array<[number, number]> = fields.length === 6 ? [[0, 59], ...fieldRanges] : fieldRanges - for (let i = 0; i < fields.length; i++) { - if (!validateCronField(fields[i], ranges[i][0], ranges[i][1])) { - return { valid: false, error: `Invalid cron field at position ${i + 1}: "${fields[i]}"` } - } - } - - return { valid: true } -} - const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise => { try { const appServer = getRunningExpressApp() @@ -229,120 +163,6 @@ const getEnabledSchedulesBatch = async (skip: number = 0, take: number = SCHEDUL // --------------------------------------------------------------------------- // Cron field helpers (used by computeNextRunAt) // --------------------------------------------------------------------------- -function _matchCronField(field: string, value: number, min: number): boolean { - if (field === '*') return true - for (const part of field.split(',')) { - if (part.includes('/')) { - const [rangeStr, stepStr] = part.split('/') - const step = parseInt(stepStr, 10) - if (isNaN(step)) continue - if (rangeStr === '*') { - if ((value - min) % step === 0) return true - } else if (rangeStr.includes('-')) { - const [start, end] = rangeStr.split('-').map(Number) - if (value >= start && value <= end && (value - start) % step === 0) return true - } else { - if (value === parseInt(rangeStr, 10)) return true - } - } else if (part.includes('-')) { - const [start, end] = part.split('-').map(Number) - if (value >= start && value <= end) return true - } else { - if (value === parseInt(part, 10)) return true - } - } - return false -} - -interface _ParsedCronFields { - minuteField: string - hourField: string - domField: string - monthField: string - dowField: string -} - -/** Parse a cron expression once so fields can be reused across many date checks. */ -function _parseCronFields(expression: string): _ParsedCronFields { - const fields = expression.trim().split(/\s+/) - const offset = fields.length === 6 ? 1 : 0 - return { - minuteField: fields[0 + offset], - hourField: fields[1 + offset], - domField: fields[2 + offset], - monthField: fields[3 + offset], - dowField: fields[4 + offset] - } -} - -/** - * Check whether a pre-parsed cron matches `date`, using a pre-built Intl.DateTimeFormat for TZ conversion. - * Both `parsed` and `fmt` should be created once outside any hot loop. - */ -function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.DateTimeFormat): boolean { - let minute: number, hour: number, dom: number, month: number, dow: number - try { - const parts = fmt.formatToParts(date) - const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) - const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun' - minute = get('minute') - hour = get('hour') % 24 - dom = get('day') - month = get('month') - dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayStr) - if (dow === -1) dow = date.getUTCDay() - } catch { - minute = date.getUTCMinutes() - hour = date.getUTCHours() - dom = date.getUTCDate() - month = date.getUTCMonth() + 1 - dow = date.getUTCDay() - } - const dowMatches = _matchCronField(parsed.dowField, dow, 0) || (dow === 0 && _matchCronField(parsed.dowField, 7, 0)) - return ( - _matchCronField(parsed.minuteField, minute, 0) && - _matchCronField(parsed.hourField, hour, 0) && - _matchCronField(parsed.domField, dom, 1) && - _matchCronField(parsed.monthField, month, 1) && - dowMatches - ) -} - -/** - * Computes the next Date after `after` (defaults to now) when the cron expression will fire. - * Searches minute-by-minute, up to 1 year ahead. Returns null if no match is found. - * - * The Intl.DateTimeFormat instance and parsed cron fields are created once before the loop - * to avoid repeated allocations on every iteration. - */ -export const computeNextRunAt = (cronExpression: string, timezone: string = 'UTC', after?: Date): Date | null => { - const start = new Date(after ? after.getTime() : Date.now()) - // Snap to start of next minute so we never return the current minute - start.setSeconds(0, 0) - start.setMinutes(start.getMinutes() + 1) - - // Hoist allocations outside the loop - const parsed = _parseCronFields(cronExpression) - const fmt = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - weekday: 'short', - hour12: false - }) - - const maxIterations = 60 * 24 * 366 // up to ~1 year of minutes - for (let i = 0; i < maxIterations; i++) { - const candidate = new Date(start.getTime() + i * 60_000) - if (_cronMatchesParsed(parsed, candidate, fmt)) { - return candidate - } - } - return null -} const updateScheduleAfterRun = async ( appDataSource: DataSource, @@ -500,172 +320,6 @@ const updateTriggerLog = async ( // ─── Visual Picker helpers ────────────────────────────────────────────────── -export interface VisualPickerInput { - scheduleFrequency: 'hourly' | 'daily' | 'weekly' | 'monthly' - scheduleOnMinute?: string | number - scheduleOnTime?: string // "HH:mm" - scheduleOnDayOfWeek?: string // comma-separated "1,3,5" (1=Mon … 7=Sun) - scheduleOnDayOfMonth?: string // comma-separated "1,15" -} - -/** - * Validate the visual-picker fields and return errors (if any). - */ -export const validateVisualPickerFields = (input: VisualPickerInput): { valid: boolean; error?: string } => { - const { scheduleFrequency, scheduleOnMinute, scheduleOnTime, scheduleOnDayOfWeek, scheduleOnDayOfMonth } = input - - if (!scheduleFrequency) { - return { valid: false, error: 'Frequency is required' } - } - if (!['hourly', 'daily', 'weekly', 'monthly'].includes(scheduleFrequency)) { - return { valid: false, error: `Invalid frequency: ${scheduleFrequency}` } - } - - if (scheduleFrequency === 'hourly') { - const minute = Number(scheduleOnMinute) - if (scheduleOnMinute === undefined || scheduleOnMinute === '' || isNaN(minute)) { - return { valid: false, error: 'On Minute is required for hourly frequency' } - } - if (!Number.isInteger(minute) || minute < 0 || minute > 59) { - return { valid: false, error: 'On Minute must be an integer between 0 and 59' } - } - } - - if (['daily', 'weekly', 'monthly'].includes(scheduleFrequency)) { - if (!scheduleOnTime) { - return { valid: false, error: 'On Time is required for daily/weekly/monthly frequency' } - } - if (!/^\d{2}:\d{2}$/.test(scheduleOnTime)) { - return { valid: false, error: 'On Time must be in HH:mm format' } - } - const [h, m] = scheduleOnTime.split(':').map(Number) - if (h < 0 || h > 23 || m < 0 || m > 59) { - return { valid: false, error: 'On Time contains out-of-range values' } - } - } - - if (scheduleFrequency === 'weekly') { - if (!scheduleOnDayOfWeek) { - return { valid: false, error: 'On Day of Week is required for weekly frequency' } - } - const days = scheduleOnDayOfWeek - .split(',') - .map((d) => d.trim()) - .filter((d) => d !== '') - for (const d of days) { - const n = Number(d) - if (isNaN(n) || !Number.isInteger(n) || n < 0 || n > 7) { - return { valid: false, error: `Invalid day of week value: ${d} (expected 0-7)` } - } - } - } - - if (scheduleFrequency === 'monthly') { - if (!scheduleOnDayOfMonth) { - return { valid: false, error: 'On Day of Month is required for monthly frequency' } - } - const days = scheduleOnDayOfMonth - .split(',') - .map((d) => d.trim()) - .filter((d) => d !== '') - for (const d of days) { - const n = Number(d) - if (isNaN(n) || !Number.isInteger(n) || n < 1 || n > 31) { - return { valid: false, error: `Invalid day of month value: ${d} (expected 1-31)` } - } - } - } - - return { valid: true } -} - -/** - * Convert visual-picker fields into a standard 5-field cron expression. - * Assumes fields have already been validated via validateVisualPickerFields. - */ -export const buildCronFromVisualPicker = (input: VisualPickerInput): string => { - const { scheduleFrequency, scheduleOnMinute, scheduleOnTime, scheduleOnDayOfWeek, scheduleOnDayOfMonth } = input - - switch (scheduleFrequency) { - case 'hourly': { - // " * * * *" - return `${Number(scheduleOnMinute)} * * * *` - } - case 'daily': { - const [h, m] = scheduleOnTime!.split(':').map(Number) - return `${m} ${h} * * *` - } - case 'weekly': { - const [h, m] = scheduleOnTime!.split(':').map(Number) - return `${m} ${h} * * ${scheduleOnDayOfWeek}` - } - case 'monthly': { - const [h, m] = scheduleOnTime!.split(':').map(Number) - return `${m} ${h} ${scheduleOnDayOfMonth} * *` - } - default: - throw new Error(`Unsupported frequency: ${scheduleFrequency}`) - } -} - -/** - * Unified helper: resolves the cron expression from a Start node's inputs, - * handling both "cronExpression" and "visualPicker" schedule types. - * Returns { valid, cronExpression?, error? }. - */ -export const resolveScheduleCron = (inputs: Record): { valid: boolean; cronExpression?: string; error?: string } => { - const scheduleType = (inputs.scheduleType as string) || 'cronExpression' - const timezone = (inputs.scheduleTimezone as string) || 'UTC' - - if (scheduleType === 'visualPicker') { - const pickerInput: VisualPickerInput = { - scheduleFrequency: inputs.scheduleFrequency, - scheduleOnMinute: inputs.scheduleOnMinute, - scheduleOnTime: inputs.scheduleOnTime, - scheduleOnDayOfWeek: inputs.scheduleOnDayOfWeek, - scheduleOnDayOfMonth: inputs.scheduleOnDayOfMonth - } - const pickerResult = validateVisualPickerFields(pickerInput) - if (!pickerResult.valid) { - return { valid: false, error: pickerResult.error } - } - const cron = buildCronFromVisualPicker(pickerInput) - // Also validate the resulting cron + timezone - const cronResult = validateCronExpression(cron, timezone) - if (!cronResult.valid) { - return { valid: false, error: cronResult.error } - } - return { valid: true, cronExpression: cron } - } - - // scheduleType === 'cronExpression' - const expression = inputs.scheduleCronExpression as string - const cronResult = validateCronExpression(expression, timezone) - if (!cronResult.valid) { - return { valid: false, error: cronResult.error } - } - return { valid: true, cronExpression: expression } -} - -/** - * Checks if the schedule can be enabled based on its inputs. - * It is used to determine the initial enabled state when creating/updating a schedule, and also to validate when toggling enabled state. - * Besides, the worker skips execution of schedules that are not valid. - */ -export const isDefaultInputValid = (defaultInput: string | undefined): boolean => { - return !!defaultInput && defaultInput !== '

' // rich text empty value -} - -/** - * Determines if a schedule can be enabled based on its inputs, including the cron expression, end date, and default input. - */ -export const canScheduleEnable = (inputs: Record): boolean => { - const cronResult = resolveScheduleCron(inputs) - const isEndDateValid = !inputs.scheduleEndDate || new Date(inputs.scheduleEndDate) > new Date() - const isInputValid = isDefaultInputValid(inputs.scheduleDefaultInput) - return cronResult.valid && isEndDateValid && isInputValid -} - export default { validateCronExpression, validateVisualPickerFields, diff --git a/packages/server/src/services/schedule/utils.test.ts b/packages/server/src/services/schedule/utils.test.ts new file mode 100644 index 00000000000..750326100fd --- /dev/null +++ b/packages/server/src/services/schedule/utils.test.ts @@ -0,0 +1,526 @@ +import { + validateCronExpression, + computeNextRunAt, + validateVisualPickerFields, + buildCronFromVisualPicker, + resolveScheduleCron, + isDefaultInputValid, + canScheduleEnable, + VisualPickerInput +} from './utils' + +// ─── validateCronExpression ─────────────────────────────────────────────────── + +describe('validateCronExpression', () => { + describe('valid expressions', () => { + it('accepts wildcard every-minute expression', () => { + expect(validateCronExpression('* * * * *')).toEqual({ valid: true }) + }) + + it('accepts specific weekday range', () => { + expect(validateCronExpression('0 9 * * 1-5')).toEqual({ valid: true }) + }) + + it('accepts step values', () => { + expect(validateCronExpression('*/5 * * * *')).toEqual({ valid: true }) + }) + + it('accepts comma-separated lists', () => { + expect(validateCronExpression('0,30 * * * *')).toEqual({ valid: true }) + }) + + it('accepts 6-field cron with seconds', () => { + expect(validateCronExpression('0 * * * * *')).toEqual({ valid: true }) + }) + + it('accepts step on a range base', () => { + expect(validateCronExpression('0/15 * * * *')).toEqual({ valid: true }) + }) + + it('accepts day-of-week value 7 (also Sunday)', () => { + expect(validateCronExpression('0 0 * * 7')).toEqual({ valid: true }) + }) + + it('accepts valid timezone', () => { + expect(validateCronExpression('0 9 * * 1-5', 'America/New_York')).toEqual({ valid: true }) + }) + }) + + describe('invalid inputs', () => { + it('rejects a non-string value', () => { + const result = validateCronExpression(null as unknown as string) + expect(result.valid).toBe(false) + expect(result.error).toBeDefined() + }) + + it('rejects an empty string', () => { + const result = validateCronExpression('') + expect(result.valid).toBe(false) + }) + + it('rejects too few fields (4 fields)', () => { + const result = validateCronExpression('* * * *') + expect(result.valid).toBe(false) + expect(result.error).toMatch(/5 fields/) + }) + + it('rejects too many fields (7 fields)', () => { + const result = validateCronExpression('0 0 0 * * * *') + expect(result.valid).toBe(false) + }) + + it('rejects minute value > 59', () => { + const result = validateCronExpression('60 * * * *') + expect(result.valid).toBe(false) + expect(result.error).toMatch(/position 1/) + }) + + it('rejects hour value > 23', () => { + const result = validateCronExpression('0 24 * * *') + expect(result.valid).toBe(false) + }) + + it('rejects day-of-month value 0', () => { + const result = validateCronExpression('0 0 0 * *') + expect(result.valid).toBe(false) + }) + + it('rejects month value 0', () => { + const result = validateCronExpression('0 0 * 0 *') + expect(result.valid).toBe(false) + }) + + it('rejects inverted range (start > end)', () => { + const result = validateCronExpression('0 0 * * 5-1') + expect(result.valid).toBe(false) + }) + + it('rejects step value of 0', () => { + const result = validateCronExpression('*/0 * * * *') + expect(result.valid).toBe(false) + }) + + it('rejects non-numeric step', () => { + const result = validateCronExpression('*/x * * * *') + expect(result.valid).toBe(false) + }) + + it('rejects trailing comma in field', () => { + const result = validateCronExpression('1, * * * *') + expect(result.valid).toBe(false) + }) + + it('rejects an invalid timezone', () => { + const result = validateCronExpression('* * * * *', 'Invalid/Timezone') + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Invalid timezone/) + }) + }) +}) + +// ─── computeNextRunAt ───────────────────────────────────────────────────────── + +describe('computeNextRunAt', () => { + it('returns a Date in the future for every-minute cron', () => { + const now = new Date() + const next = computeNextRunAt('* * * * *', 'UTC', now) + expect(next).not.toBeNull() + expect(next!.getTime()).toBeGreaterThan(now.getTime()) + }) + + it('returns a date at least 1 minute after the provided reference', () => { + const ref = new Date('2025-01-01T12:00:00Z') + const next = computeNextRunAt('* * * * *', 'UTC', ref) + expect(next!.getTime()).toBeGreaterThanOrEqual(ref.getTime() + 60_000) + }) + + it('finds the next occurrence of a specific daily cron', () => { + // Run at 09:00 UTC every day β€” provide reference at 08:00 same day + const ref = new Date('2025-06-15T08:00:00Z') + const next = computeNextRunAt('0 9 * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCHours()).toBe(9) + expect(next!.getUTCMinutes()).toBe(0) + expect(next!.getUTCDate()).toBe(15) + }) + + it('advances to the next day when target time has passed today', () => { + // Run at 06:00 UTC β€” reference is already past 06:00 + const ref = new Date('2025-06-15T10:00:00Z') + const next = computeNextRunAt('0 6 * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(16) + expect(next!.getUTCHours()).toBe(6) + }) + + it('uses the provided timezone to compute the next run', () => { + // 0 9 * * * in America/New_York β€” find next occurrence after a UTC reference + const ref = new Date('2025-06-15T12:00:00Z') // 08:00 NY time + const next = computeNextRunAt('0 9 * * *', 'America/New_York', ref) + expect(next).not.toBeNull() + // Should fire at 09:00 NY = 13:00 UTC on June 15 + expect(next!.getUTCHours()).toBe(13) + expect(next!.getUTCDate()).toBe(15) + }) + + it('returns null for an expression that never matches (e.g., Feb 31)', () => { + // Feb 31 never exists β€” this should exhaust the search window + const next = computeNextRunAt('0 0 31 2 *', 'UTC') + expect(next).toBeNull() + }) + + it('returns seconds-aligned output (seconds and ms zeroed)', () => { + const ref = new Date('2025-01-01T00:00:30Z') + const next = computeNextRunAt('* * * * *', 'UTC', ref) + expect(next!.getUTCSeconds()).toBe(0) + expect(next!.getUTCMilliseconds()).toBe(0) + }) + + it('aligns to next stepped minute for numeric-base step syntax (0/15)', () => { + // 0/15 * * * * fires at :00, :15, :30, :45 β€” reference at :07 should yield :15 + const ref = new Date('2025-01-01T12:07:00Z') + const next = computeNextRunAt('0/15 * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCHours()).toBe(12) + expect(next!.getUTCMinutes()).toBe(15) + expect(next!.getUTCSeconds()).toBe(0) + }) +}) + +// ─── validateVisualPickerFields ─────────────────────────────────────────────── + +describe('validateVisualPickerFields', () => { + describe('common validations', () => { + it('rejects missing frequency', () => { + const result = validateVisualPickerFields({ scheduleFrequency: '' as any }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Frequency is required/) + }) + + it('rejects an unsupported frequency', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'yearly' as any }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Invalid frequency/) + }) + }) + + describe('hourly', () => { + it('rejects missing scheduleOnMinute', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'hourly' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/On Minute is required/) + }) + + it('rejects empty string scheduleOnMinute', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'hourly', scheduleOnMinute: '' }) + expect(result.valid).toBe(false) + }) + + it('rejects minute > 59', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'hourly', scheduleOnMinute: 60 }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/0 and 59/) + }) + + it('rejects minute < 0', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'hourly', scheduleOnMinute: -1 }) + expect(result.valid).toBe(false) + }) + + it('accepts valid minute 0', () => { + expect(validateVisualPickerFields({ scheduleFrequency: 'hourly', scheduleOnMinute: 0 })).toEqual({ valid: true }) + }) + + it('accepts valid minute 30', () => { + expect(validateVisualPickerFields({ scheduleFrequency: 'hourly', scheduleOnMinute: 30 })).toEqual({ valid: true }) + }) + + it('accepts minute as a string number', () => { + expect(validateVisualPickerFields({ scheduleFrequency: 'hourly', scheduleOnMinute: '45' })).toEqual({ valid: true }) + }) + }) + + describe('daily', () => { + it('rejects missing scheduleOnTime', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'daily' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/On Time is required/) + }) + + it('rejects time in wrong format', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'daily', scheduleOnTime: '9:00' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/HH:mm/) + }) + + it('rejects invalid hour', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'daily', scheduleOnTime: '24:00' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/out-of-range/) + }) + + it('rejects invalid minute', () => { + const result = validateVisualPickerFields({ scheduleFrequency: 'daily', scheduleOnTime: '09:60' }) + expect(result.valid).toBe(false) + }) + + it('accepts valid daily time', () => { + expect(validateVisualPickerFields({ scheduleFrequency: 'daily', scheduleOnTime: '09:30' })).toEqual({ valid: true }) + }) + }) + + describe('weekly', () => { + const base: VisualPickerInput = { scheduleFrequency: 'weekly', scheduleOnTime: '09:00' } + + it('rejects missing scheduleOnDayOfWeek', () => { + const result = validateVisualPickerFields(base) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Day of Week is required/) + }) + + it('rejects invalid day value (8)', () => { + const result = validateVisualPickerFields({ ...base, scheduleOnDayOfWeek: '8' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Invalid day of week/) + }) + + it('rejects day 0 (not emitted by the UI; use 7 for Sunday)', () => { + const result = validateVisualPickerFields({ ...base, scheduleOnDayOfWeek: '0' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Invalid day of week/) + }) + + it('accepts day 7 (Sunday)', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfWeek: '7' })).toEqual({ valid: true }) + }) + + it('accepts comma-separated days', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfWeek: '1,3,5' })).toEqual({ valid: true }) + }) + }) + + describe('monthly', () => { + const base: VisualPickerInput = { scheduleFrequency: 'monthly', scheduleOnTime: '08:00' } + + it('rejects missing scheduleOnDayOfMonth', () => { + const result = validateVisualPickerFields(base) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Day of Month is required/) + }) + + it('rejects day of month 0', () => { + const result = validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: '0' }) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/Invalid day of month/) + }) + + it('rejects day of month 32', () => { + const result = validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: '32' }) + expect(result.valid).toBe(false) + }) + + it('accepts valid days', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: '1,15' })).toEqual({ valid: true }) + }) + + it('accepts last day of month (31)', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: '31' })).toEqual({ valid: true }) + }) + }) +}) + +// ─── buildCronFromVisualPicker ──────────────────────────────────────────────── + +describe('buildCronFromVisualPicker', () => { + it('builds hourly cron with correct minute', () => { + expect(buildCronFromVisualPicker({ scheduleFrequency: 'hourly', scheduleOnMinute: 30 })).toBe('30 * * * *') + }) + + it('builds daily cron at 09:30', () => { + expect(buildCronFromVisualPicker({ scheduleFrequency: 'daily', scheduleOnTime: '09:30' })).toBe('30 9 * * *') + }) + + it('builds daily cron at midnight (00:00)', () => { + expect(buildCronFromVisualPicker({ scheduleFrequency: 'daily', scheduleOnTime: '00:00' })).toBe('0 0 * * *') + }) + + it('builds weekly cron for Mon/Wed/Fri at 08:00', () => { + expect(buildCronFromVisualPicker({ scheduleFrequency: 'weekly', scheduleOnTime: '08:00', scheduleOnDayOfWeek: '1,3,5' })).toBe( + '0 8 * * 1,3,5' + ) + }) + + it('builds monthly cron for the 1st and 15th at 09:00', () => { + expect(buildCronFromVisualPicker({ scheduleFrequency: 'monthly', scheduleOnTime: '09:00', scheduleOnDayOfMonth: '1,15' })).toBe( + '0 9 1,15 * *' + ) + }) + + it('throws for an unsupported frequency', () => { + expect(() => buildCronFromVisualPicker({ scheduleFrequency: 'yearly' as any })).toThrow(/Unsupported frequency/) + }) +}) + +// ─── resolveScheduleCron ────────────────────────────────────────────────────── + +describe('resolveScheduleCron', () => { + describe('cronExpression type (default)', () => { + it('returns valid cron when expression is valid', () => { + const result = resolveScheduleCron({ scheduleCronExpression: '0 9 * * 1-5' }) + expect(result).toEqual({ valid: true, cronExpression: '0 9 * * 1-5' }) + }) + + it('defaults to cronExpression type when scheduleType is not set', () => { + const result = resolveScheduleCron({ scheduleCronExpression: '* * * * *' }) + expect(result.valid).toBe(true) + expect(result.cronExpression).toBe('* * * * *') + }) + + it('returns invalid when cron expression is invalid', () => { + const result = resolveScheduleCron({ scheduleCronExpression: 'not-a-cron' }) + expect(result.valid).toBe(false) + expect(result.error).toBeDefined() + }) + + it('validates timezone from inputs', () => { + const result = resolveScheduleCron({ + scheduleCronExpression: '0 9 * * *', + scheduleTimezone: 'Invalid/Zone' + }) + expect(result.valid).toBe(false) + }) + }) + + describe('visualPicker type', () => { + it('converts valid visual picker to cron expression', () => { + const result = resolveScheduleCron({ + scheduleType: 'visualPicker', + scheduleFrequency: 'daily', + scheduleOnTime: '09:00', + scheduleTimezone: 'UTC' + }) + expect(result.valid).toBe(true) + expect(result.cronExpression).toBe('0 9 * * *') + }) + + it('returns invalid when visual picker fields are invalid', () => { + const result = resolveScheduleCron({ + scheduleType: 'visualPicker', + scheduleFrequency: 'hourly' + // missing scheduleOnMinute + }) + expect(result.valid).toBe(false) + expect(result.error).toBeDefined() + }) + + it('propagates timezone to cron validation', () => { + const result = resolveScheduleCron({ + scheduleType: 'visualPicker', + scheduleFrequency: 'daily', + scheduleOnTime: '09:00', + scheduleTimezone: 'Asia/Tokyo' + }) + expect(result.valid).toBe(true) + }) + }) +}) + +// ─── isDefaultInputValid ────────────────────────────────────────────────────── + +describe('isDefaultInputValid', () => { + it('returns false for undefined', () => { + expect(isDefaultInputValid(undefined)).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isDefaultInputValid('')).toBe(false) + }) + + it('returns false for rich-text empty value', () => { + expect(isDefaultInputValid('

')).toBe(false) + }) + + it('returns true for a non-empty string', () => { + expect(isDefaultInputValid('Hello from scheduler')).toBe(true) + }) + + it('returns true for a whitespace-only non-empty string', () => { + // The check only tests truthiness and the specific empty rich-text value + expect(isDefaultInputValid(' ')).toBe(true) + }) +}) + +// ─── canScheduleEnable ──────────────────────────────────────────────────────── + +describe('canScheduleEnable', () => { + const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString() // 30 days from now + const pastDate = new Date(Date.now() - 1000 * 60 * 60).toISOString() // 1 hour ago + + it('returns false when cron expression is invalid', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: 'bad-cron', + scheduleDefaultInput: 'hello', + scheduleEndDate: futureDate + }) + ).toBe(false) + }) + + it('returns false when end date is in the past', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '* * * * *', + scheduleDefaultInput: 'hello', + scheduleEndDate: pastDate + }) + ).toBe(false) + }) + + it('returns false when default input is missing', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '* * * * *', + scheduleDefaultInput: undefined + }) + ).toBe(false) + }) + + it('returns false when default input is rich-text empty', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '* * * * *', + scheduleDefaultInput: '

' + }) + ).toBe(false) + }) + + it('returns true when all conditions are valid (no end date)', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleDefaultInput: 'Generate the daily report' + }) + ).toBe(true) + }) + + it('returns true when all conditions are valid with future end date', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleDefaultInput: 'Generate the daily report', + scheduleEndDate: futureDate + }) + ).toBe(true) + }) + + it('returns true for visual picker type when all fields are valid', () => { + expect( + canScheduleEnable({ + scheduleType: 'visualPicker', + scheduleFrequency: 'daily', + scheduleOnTime: '09:00', + scheduleDefaultInput: 'Run daily job' + }) + ).toBe(true) + }) +}) diff --git a/packages/server/src/services/schedule/utils.ts b/packages/server/src/services/schedule/utils.ts new file mode 100644 index 00000000000..0768dd39883 --- /dev/null +++ b/packages/server/src/services/schedule/utils.ts @@ -0,0 +1,382 @@ +/** + * Pure utility functions for schedule management. + * No server, database, or Express dependencies β€” safe to import and test in isolation. + */ + +// ─── Cron expression validation ────────────────────────────────────────────── + +/** + * Validates a cron expression and returns parsed info. + * Uses a lightweight regex-based check without external dependencies. + * + * Supports standard 5-field cron: minute hour day month weekday + */ +export const validateCronExpression = (expression: string, timezone: string = 'UTC'): { valid: boolean; error?: string } => { + if (!expression || typeof expression !== 'string') { + return { valid: false, error: 'Cron expression must be a non-empty string' } + } + + const trimmed = expression.trim() + const fields = trimmed.split(/\s+/) + + if (fields.length !== 5 && fields.length !== 6) { + return { + valid: false, + error: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)' + } + } + + // Validate timezone + try { + Intl.DateTimeFormat('en-US', { timeZone: timezone }) + } catch { + return { valid: false, error: `Invalid timezone: ${timezone}` } + } + + // Returns true if s is a valid integer in [min, max] or a valid range "start-end" + const isValidRangeOrNumber = (s: string, min: number, max: number): boolean => { + const dashIdx = s.indexOf('-') + if (dashIdx !== -1) { + const startStr = s.slice(0, dashIdx) + const endStr = s.slice(dashIdx + 1) + if (!/^\d+$/.test(startStr) || !/^\d+$/.test(endStr)) return false + const start = parseInt(startStr, 10) + const end = parseInt(endStr, 10) + return start >= min && start <= max && end >= min && end <= max && start <= end + } + if (!/^\d+$/.test(s)) return false + const n = parseInt(s, 10) + return n >= min && n <= max + } + + // Validate a single cron field: supports *, numbers, ranges (n-m), steps (*/s, n/s, n-m/s), and comma-separated lists + const validateCronField = (field: string, min: number, max: number): boolean => { + const parts = field.split(',') + if (parts.some((p) => p === '')) return false // catches leading/trailing/consecutive commas + + for (const part of parts) { + const slashIdx = part.indexOf('/') + if (slashIdx !== -1) { + const base = part.slice(0, slashIdx) + const stepStr = part.slice(slashIdx + 1) + if (!/^\d+$/.test(stepStr)) return false + const step = parseInt(stepStr, 10) + if (step < 1) return false + // Base must be *, a plain number, or a range + if (base !== '*' && !isValidRangeOrNumber(base, min, max)) return false + } else if (part !== '*') { + if (!isValidRangeOrNumber(part, min, max)) return false + } + } + return true + } + + // Per-position field ranges [min, max]: minute hour day-of-month month day-of-week + const fieldRanges: Array<[number, number]> = [ + [0, 59], // minutes (or seconds when 6-field) + [0, 23], // hours + [1, 31], // day of month + [1, 12], // month + [0, 7] // day of week (0 and 7 both represent Sunday) + ] + + // For 6-field cron, prepend an extra seconds range (same as minutes: 0-59) + const ranges: Array<[number, number]> = fields.length === 6 ? [[0, 59], ...fieldRanges] : fieldRanges + for (let i = 0; i < fields.length; i++) { + if (!validateCronField(fields[i], ranges[i][0], ranges[i][1])) { + return { valid: false, error: `Invalid cron field at position ${i + 1}: "${fields[i]}"` } + } + } + + return { valid: true } +} + +// --------------------------------------------------------------------------- +// Cron field helpers (used by computeNextRunAt) +// --------------------------------------------------------------------------- +function _matchCronField(field: string, value: number, min: number): boolean { + if (field === '*') return true + for (const part of field.split(',')) { + if (part.includes('/')) { + const [rangeStr, stepStr] = part.split('/') + const step = parseInt(stepStr, 10) + if (isNaN(step)) continue + if (rangeStr === '*') { + if ((value - min) % step === 0) return true + } else if (rangeStr.includes('-')) { + const [start, end] = rangeStr.split('-').map(Number) + if (value >= start && value <= end && (value - start) % step === 0) return true + } else { + const start = parseInt(rangeStr, 10) + if (value >= start && (value - start) % step === 0) return true + } + } else if (part.includes('-')) { + const [start, end] = part.split('-').map(Number) + if (value >= start && value <= end) return true + } else { + if (value === parseInt(part, 10)) return true + } + } + return false +} + +interface _ParsedCronFields { + minuteField: string + hourField: string + domField: string + monthField: string + dowField: string +} + +/** Parse a cron expression once so fields can be reused across many date checks. */ +function _parseCronFields(expression: string): _ParsedCronFields { + const fields = expression.trim().split(/\s+/) + const offset = fields.length === 6 ? 1 : 0 + return { + minuteField: fields[0 + offset], + hourField: fields[1 + offset], + domField: fields[2 + offset], + monthField: fields[3 + offset], + dowField: fields[4 + offset] + } +} + +/** + * Check whether a pre-parsed cron matches `date`, using a pre-built Intl.DateTimeFormat for TZ conversion. + * Both `parsed` and `fmt` should be created once outside any hot loop. + */ +function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.DateTimeFormat): boolean { + let minute: number, hour: number, dom: number, month: number, dow: number + try { + const parts = fmt.formatToParts(date) + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) + const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun' + minute = get('minute') + hour = get('hour') % 24 + dom = get('day') + month = get('month') + dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayStr) + if (dow === -1) dow = date.getUTCDay() + } catch { + minute = date.getUTCMinutes() + hour = date.getUTCHours() + dom = date.getUTCDate() + month = date.getUTCMonth() + 1 + dow = date.getUTCDay() + } + const dowMatches = _matchCronField(parsed.dowField, dow, 0) || (dow === 0 && _matchCronField(parsed.dowField, 7, 0)) + return ( + _matchCronField(parsed.minuteField, minute, 0) && + _matchCronField(parsed.hourField, hour, 0) && + _matchCronField(parsed.domField, dom, 1) && + _matchCronField(parsed.monthField, month, 1) && + dowMatches + ) +} + +/** + * Computes the next Date after `after` (defaults to now) when the cron expression will fire. + * Searches minute-by-minute, up to 1 year ahead. Returns null if no match is found. + * + * The Intl.DateTimeFormat instance and parsed cron fields are created once before the loop + * to avoid repeated allocations on every iteration. + * + * For 6-field cron expressions with seconds, the search still only considers minute-level matches and ignores the seconds field (i.e. treats it as if it were "0"). + * This is because the scheduler only triggers at minute-level granularity, so the seconds field is not relevant for computing the next run time. + */ +export const computeNextRunAt = (cronExpression: string, timezone: string = 'UTC', after?: Date): Date | null => { + const start = new Date(after ? after.getTime() : Date.now()) + // Snap to start of next minute so we never return the current minute + start.setSeconds(0, 0) + start.setMinutes(start.getMinutes() + 1) + + // Hoist allocations outside the loop + const parsed = _parseCronFields(cronExpression) + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + weekday: 'short', + hour12: false + }) + + const maxIterations = 60 * 24 * 366 // up to ~1 year of minutes + for (let i = 0; i < maxIterations; i++) { + const candidate = new Date(start.getTime() + i * 60_000) + if (_cronMatchesParsed(parsed, candidate, fmt)) { + return candidate + } + } + return null +} + +// ─── Visual Picker helpers ──────────────────────────────────────────────────── + +export interface VisualPickerInput { + scheduleFrequency: 'hourly' | 'daily' | 'weekly' | 'monthly' + scheduleOnMinute?: string | number + scheduleOnTime?: string // "HH:mm" + scheduleOnDayOfWeek?: string // comma-separated "1,3,5" (1=Mon … 6=Sat, 7=Sun) + scheduleOnDayOfMonth?: string // comma-separated "1,15" +} + +/** + * Validate the visual-picker fields and return errors (if any). + */ +export const validateVisualPickerFields = (input: VisualPickerInput): { valid: boolean; error?: string } => { + const { scheduleFrequency, scheduleOnMinute, scheduleOnTime, scheduleOnDayOfWeek, scheduleOnDayOfMonth } = input + + if (!scheduleFrequency) { + return { valid: false, error: 'Frequency is required' } + } + if (!['hourly', 'daily', 'weekly', 'monthly'].includes(scheduleFrequency)) { + return { valid: false, error: `Invalid frequency: ${scheduleFrequency}` } + } + + if (scheduleFrequency === 'hourly') { + const minute = Number(scheduleOnMinute) + if (scheduleOnMinute === undefined || scheduleOnMinute === '' || isNaN(minute)) { + return { valid: false, error: 'On Minute is required for hourly frequency' } + } + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + return { valid: false, error: 'On Minute must be an integer between 0 and 59' } + } + } + + if (['daily', 'weekly', 'monthly'].includes(scheduleFrequency)) { + if (!scheduleOnTime) { + return { valid: false, error: 'On Time is required for daily/weekly/monthly frequency' } + } + if (!/^\d{2}:\d{2}$/.test(scheduleOnTime)) { + return { valid: false, error: 'On Time must be in HH:mm format' } + } + const [h, m] = scheduleOnTime.split(':').map(Number) + if (h < 0 || h > 23 || m < 0 || m > 59) { + return { valid: false, error: 'On Time contains out-of-range values' } + } + } + + if (scheduleFrequency === 'weekly') { + if (!scheduleOnDayOfWeek) { + return { valid: false, error: 'On Day of Week is required for weekly frequency' } + } + const days = scheduleOnDayOfWeek + .split(',') + .map((d) => d.trim()) + .filter((d) => d !== '') + for (const d of days) { + const n = Number(d) + if (isNaN(n) || !Number.isInteger(n) || n < 1 || n > 7) { + return { valid: false, error: `Invalid day of week value: ${d} (expected 1-7)` } + } + } + } + + if (scheduleFrequency === 'monthly') { + if (!scheduleOnDayOfMonth) { + return { valid: false, error: 'On Day of Month is required for monthly frequency' } + } + const days = scheduleOnDayOfMonth + .split(',') + .map((d) => d.trim()) + .filter((d) => d !== '') + for (const d of days) { + const n = Number(d) + if (isNaN(n) || !Number.isInteger(n) || n < 1 || n > 31) { + return { valid: false, error: `Invalid day of month value: ${d} (expected 1-31)` } + } + } + } + + return { valid: true } +} + +/** + * Convert visual-picker fields into a standard 5-field cron expression. + * Assumes fields have already been validated via validateVisualPickerFields. + */ +export const buildCronFromVisualPicker = (input: VisualPickerInput): string => { + const { scheduleFrequency, scheduleOnMinute, scheduleOnTime, scheduleOnDayOfWeek, scheduleOnDayOfMonth } = input + + switch (scheduleFrequency) { + case 'hourly': { + // " * * * *" + return `${Number(scheduleOnMinute)} * * * *` + } + case 'daily': { + const [h, m] = scheduleOnTime!.split(':').map(Number) + return `${m} ${h} * * *` + } + case 'weekly': { + const [h, m] = scheduleOnTime!.split(':').map(Number) + return `${m} ${h} * * ${scheduleOnDayOfWeek}` + } + case 'monthly': { + const [h, m] = scheduleOnTime!.split(':').map(Number) + return `${m} ${h} ${scheduleOnDayOfMonth} * *` + } + default: + throw new Error(`Unsupported frequency: ${scheduleFrequency}`) + } +} + +/** + * Unified helper: resolves the cron expression from a Start node's inputs, + * handling both "cronExpression" and "visualPicker" schedule types. + * Returns { valid, cronExpression?, error? }. + */ +export const resolveScheduleCron = (inputs: Record): { valid: boolean; cronExpression?: string; error?: string } => { + const scheduleType = (inputs.scheduleType as string) || 'cronExpression' + const timezone = (inputs.scheduleTimezone as string) || 'UTC' + + if (scheduleType === 'visualPicker') { + const pickerInput: VisualPickerInput = { + scheduleFrequency: inputs.scheduleFrequency, + scheduleOnMinute: inputs.scheduleOnMinute, + scheduleOnTime: inputs.scheduleOnTime, + scheduleOnDayOfWeek: inputs.scheduleOnDayOfWeek, + scheduleOnDayOfMonth: inputs.scheduleOnDayOfMonth + } + const pickerResult = validateVisualPickerFields(pickerInput) + if (!pickerResult.valid) { + return { valid: false, error: pickerResult.error } + } + const cron = buildCronFromVisualPicker(pickerInput) + // Also validate the resulting cron + timezone + const cronResult = validateCronExpression(cron, timezone) + if (!cronResult.valid) { + return { valid: false, error: cronResult.error } + } + return { valid: true, cronExpression: cron } + } + + // scheduleType === 'cronExpression' + const expression = inputs.scheduleCronExpression as string + const cronResult = validateCronExpression(expression, timezone) + if (!cronResult.valid) { + return { valid: false, error: cronResult.error } + } + return { valid: true, cronExpression: expression } +} + +/** + * Checks if the default input is valid for a scheduled flow. + * It is used to determine the initial enabled state when creating/updating a schedule, and also to validate when toggling enabled state. + * Besides, the worker skips execution of schedules that are not valid. + */ +export const isDefaultInputValid = (defaultInput: string | undefined): boolean => { + return !!defaultInput && defaultInput !== '

' // rich text empty value +} + +/** + * Determines if a schedule can be enabled based on its inputs, including the cron expression, end date, and default input. + */ +export const canScheduleEnable = (inputs: Record): boolean => { + const cronResult = resolveScheduleCron(inputs) + const isEndDateValid = !inputs.scheduleEndDate || new Date(inputs.scheduleEndDate) > new Date() + const isInputValid = isDefaultInputValid(inputs.scheduleDefaultInput) + return cronResult.valid && isEndDateValid && isInputValid +} From ce573c64b6ff6abb472c8d8ff2bcaaa77db134af Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Tue, 24 Mar 2026 21:57:50 +0700 Subject: [PATCH 04/15] feat(schedule): remove timestamp in schedule tabes to support sqlite - Change the way to update the existing schedule to avoid typescript issue when building. - Add merge function in mockRepo in schedule service test. --- .../src/database/entities/ScheduleRecord.ts | 12 ++++----- .../database/entities/ScheduleTriggerLog.ts | 2 +- .../src/services/schedule/index.test.ts | 4 ++- .../server/src/services/schedule/index.ts | 26 ++++++++++++------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/server/src/database/entities/ScheduleRecord.ts b/packages/server/src/database/entities/ScheduleRecord.ts index b9c05d0a72a..55f71994c65 100644 --- a/packages/server/src/database/entities/ScheduleRecord.ts +++ b/packages/server/src/database/entities/ScheduleRecord.ts @@ -39,15 +39,15 @@ export class ScheduleRecord { @Column({ nullable: true, type: 'text' }) defaultInput?: string - @Column({ nullable: true, type: 'timestamp' }) - lastRunAt?: Date | null + @Column() + lastRunAt: Date - @Column({ nullable: true, type: 'timestamp' }) - nextRunAt?: Date | null + @Column() + nextRunAt: Date /** Optional date/time after which the schedule will no longer fire */ - @Column({ nullable: true, type: 'timestamp' }) - endDate?: Date | null + @Column() + endDate: Date @Column({ type: 'varchar' }) workspaceId: string diff --git a/packages/server/src/database/entities/ScheduleTriggerLog.ts b/packages/server/src/database/entities/ScheduleTriggerLog.ts index 4d3dbe4b1c7..a3a5d91115e 100644 --- a/packages/server/src/database/entities/ScheduleTriggerLog.ts +++ b/packages/server/src/database/entities/ScheduleTriggerLog.ts @@ -39,7 +39,7 @@ export class ScheduleTriggerLog { @Column({ nullable: true, type: 'integer' }) elapsedTimeMs?: number - @Column({ type: 'timestamp' }) + @Column() scheduledAt: Date @Column({ type: 'varchar' }) diff --git a/packages/server/src/services/schedule/index.test.ts b/packages/server/src/services/schedule/index.test.ts index d5f75ed27d2..e74b3a8a83b 100644 --- a/packages/server/src/services/schedule/index.test.ts +++ b/packages/server/src/services/schedule/index.test.ts @@ -12,7 +12,8 @@ const mockRepo = { create: jest.fn(), save: jest.fn(), delete: jest.fn(), - update: jest.fn() + update: jest.fn(), + merge: jest.fn() } const mockAppDataSource = { @@ -147,6 +148,7 @@ describe('createOrUpdateSchedule', () => { const existing = makeRecord() const saved = { ...existing, cronExpression: '0 9 * * 1-5' } mockRepo.findOne.mockResolvedValue(existing) + mockRepo.merge.mockReturnValue(saved) mockRepo.save.mockResolvedValue(saved) const result = await scheduleService.createOrUpdateSchedule(baseInput) diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts index 4e23346622e..3283bbca242 100644 --- a/packages/server/src/services/schedule/index.ts +++ b/packages/server/src/services/schedule/index.ts @@ -17,6 +17,7 @@ import { buildCronFromVisualPicker, canScheduleEnable } from './utils' +import { ICommonObject } from 'flowise-components' export { validateCronExpression, @@ -71,7 +72,7 @@ const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise Date: Tue, 24 Mar 2026 17:15:07 -0700 Subject: [PATCH 05/15] fix(schedule): add nullable to optional entity columns and entity interfaces - Add nullable: true to lastRunAt, nextRunAt, endDate on ScheduleRecord entity to match migration DDL and prevent runtime errors - Add IScheduleRecord and IScheduleTriggerLog interfaces to Interface.ts - Have entities implement their respective interfaces per codebase convention --- packages/server/src/Interface.ts | 31 +++++++++++++++++++ .../src/database/entities/ScheduleRecord.ts | 15 ++++----- .../database/entities/ScheduleTriggerLog.ts | 3 +- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 60175e23528..44ac233dd51 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -181,6 +181,37 @@ export interface IExecution { workspaceId: string } +export interface IScheduleRecord { + id: string + triggerType: string + targetId: string + nodeId?: string + cronExpression: string + timezone: string + enabled: boolean + defaultInput?: string + lastRunAt?: Date + nextRunAt?: Date + endDate?: Date + workspaceId: string + createdDate: Date + updatedDate: Date +} + +export interface IScheduleTriggerLog { + id: string + scheduleRecordId: string + triggerType: string + targetId: string + executionId?: string + status: string + error?: string + elapsedTimeMs?: number + scheduledAt: Date + workspaceId: string + createdDate: Date +} + export interface IComponentNodes { [key: string]: INode } diff --git a/packages/server/src/database/entities/ScheduleRecord.ts b/packages/server/src/database/entities/ScheduleRecord.ts index 55f71994c65..8d0f066bb90 100644 --- a/packages/server/src/database/entities/ScheduleRecord.ts +++ b/packages/server/src/database/entities/ScheduleRecord.ts @@ -1,12 +1,13 @@ /* eslint-disable */ import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm' +import { IScheduleRecord } from '../../Interface' export enum ScheduleTriggerType { AGENTFLOW = 'AGENTFLOW' } @Entity() -export class ScheduleRecord { +export class ScheduleRecord implements IScheduleRecord { @PrimaryGeneratedColumn('uuid') id: string @@ -39,15 +40,15 @@ export class ScheduleRecord { @Column({ nullable: true, type: 'text' }) defaultInput?: string - @Column() - lastRunAt: Date + @Column({ nullable: true }) + lastRunAt?: Date - @Column() - nextRunAt: Date + @Column({ nullable: true }) + nextRunAt?: Date /** Optional date/time after which the schedule will no longer fire */ - @Column() - endDate: Date + @Column({ nullable: true }) + endDate?: Date @Column({ type: 'varchar' }) workspaceId: string diff --git a/packages/server/src/database/entities/ScheduleTriggerLog.ts b/packages/server/src/database/entities/ScheduleTriggerLog.ts index a3a5d91115e..b9aa2a7095d 100644 --- a/packages/server/src/database/entities/ScheduleTriggerLog.ts +++ b/packages/server/src/database/entities/ScheduleTriggerLog.ts @@ -1,5 +1,6 @@ /* eslint-disable */ import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm' +import { IScheduleTriggerLog } from '../../Interface' import { ScheduleTriggerType } from './ScheduleRecord' export enum ScheduleTriggerStatus { @@ -11,7 +12,7 @@ export enum ScheduleTriggerStatus { } @Entity() -export class ScheduleTriggerLog { +export class ScheduleTriggerLog implements IScheduleTriggerLog { @PrimaryGeneratedColumn('uuid') id: string From 6e5c8ebbebaae8461843dca269d76d6e7a4b5e56 Mon Sep 17 00:00:00 2001 From: Jackie Chui Date: Mon, 6 Apr 2026 13:37:35 -0700 Subject: [PATCH 06/15] fixed tests and hide schedule input from package/agentflow --- packages/components/nodes/agentflow/Start/Start.ts | 3 ++- packages/server/__mocks__/typeorm.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 1f1cc3cd09f..7cf459b6c92 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -44,7 +44,8 @@ class Start_Agentflow implements INode { { label: 'Schedule Input', name: 'scheduleInput', - description: 'Start the workflow on a recurring schedule (cron)' + description: 'Start the workflow on a recurring schedule (cron)', + client: ['agentflowv2'] } ], default: 'chatInput' diff --git a/packages/server/__mocks__/typeorm.ts b/packages/server/__mocks__/typeorm.ts index 6462bee1445..b75d11cb920 100644 --- a/packages/server/__mocks__/typeorm.ts +++ b/packages/server/__mocks__/typeorm.ts @@ -8,6 +8,8 @@ const decorator = (): (() => void) => () => {} module.exports = { + Column: decorator, + Entity: decorator, PrimaryGeneratedColumn: decorator, PrimaryColumn: decorator, CreateDateColumn: decorator, From 9c2524cf23ff6b80edbe21a6b61cec3b920b8148 Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Tue, 7 Apr 2026 22:29:09 +0700 Subject: [PATCH 07/15] feat: integrate IdentityManager into worker and queue management - Remove supporting cron express with 6 fields (second) - Calculate the prediction usage in cron job --- packages/server/src/commands/worker.ts | 20 ++++++++++-- packages/server/src/index.ts | 1 + packages/server/src/queue/QueueManager.ts | 6 +++- packages/server/src/queue/ScheduleBeat.ts | 3 +- .../server/src/queue/ScheduleExecutor.test.ts | 19 +++++++++-- packages/server/src/queue/ScheduleExecutor.ts | 32 +++++++++++++++---- .../server/src/queue/ScheduleQueue.test.ts | 3 +- packages/server/src/queue/ScheduleQueue.ts | 7 +++- .../src/services/schedule/utils.test.ts | 6 ++-- .../server/src/services/schedule/utils.ts | 22 ++++++------- 10 files changed, 89 insertions(+), 30 deletions(-) diff --git a/packages/server/src/commands/worker.ts b/packages/server/src/commands/worker.ts index fe08fedf832..5a010c947dd 100644 --- a/packages/server/src/commands/worker.ts +++ b/packages/server/src/commands/worker.ts @@ -8,6 +8,7 @@ import { CachePool } from '../CachePool' import { QueueEvents, QueueEventsListener } from 'bullmq' import { AbortControllerPool } from '../AbortControllerPool' import { UsageCacheManager } from '../UsageCacheManager' +import { IdentityManager } from '../IdentityManager' interface CustomListener extends QueueEventsListener { abort: (args: { id: string }, id: string) => void @@ -21,7 +22,8 @@ export default class Worker extends BaseCommand { async run(): Promise { logger.info('Starting Flowise Worker...') - const { appDataSource, telemetry, componentNodes, cachePool, abortControllerPool, usageCacheManager } = await this.prepareData() + const { appDataSource, telemetry, componentNodes, cachePool, abortControllerPool, usageCacheManager, identityManager } = + await this.prepareData() const queueManager = QueueManager.getInstance() queueManager.setupAllQueues({ @@ -30,7 +32,8 @@ export default class Worker extends BaseCommand { cachePool, appDataSource, abortControllerPool, - usageCacheManager + usageCacheManager, + identityManager }) /** Prediction */ @@ -84,7 +87,18 @@ export default class Worker extends BaseCommand { // Initialize usage cache manager const usageCacheManager = await UsageCacheManager.getInstance() - return { appDataSource, telemetry, componentNodes: nodesPool.componentNodes, cachePool, abortControllerPool, usageCacheManager } + // Initialize identity manager + const identityManager = await IdentityManager.getInstance() + + return { + appDataSource, + telemetry, + componentNodes: nodesPool.componentNodes, + cachePool, + abortControllerPool, + usageCacheManager, + identityManager + } } async catch(error: Error) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6e1ca1a6171..30de4e73604 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -144,6 +144,7 @@ export class App { appDataSource: this.AppDataSource, abortControllerPool: this.abortControllerPool, usageCacheManager: this.usageCacheManager, + identityManager: this.identityManager, serverAdapter }) logger.info('βœ… [Queue]: All queues setup successfully') diff --git a/packages/server/src/queue/QueueManager.ts b/packages/server/src/queue/QueueManager.ts index 7740e1bbb6b..865bc46f41c 100644 --- a/packages/server/src/queue/QueueManager.ts +++ b/packages/server/src/queue/QueueManager.ts @@ -13,6 +13,7 @@ import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' import { Express } from 'express' import { UsageCacheManager } from '../UsageCacheManager' import { ExpressAdapter } from '@bull-board/express' +import { IdentityManager } from '../IdentityManager' const QUEUE_NAME = process.env.QUEUE_NAME || 'flowise-queue' @@ -120,6 +121,7 @@ export class QueueManager { appDataSource, abortControllerPool, usageCacheManager, + identityManager, serverAdapter }: { componentNodes: IComponentNodes @@ -128,6 +130,7 @@ export class QueueManager { appDataSource: DataSource abortControllerPool: AbortControllerPool usageCacheManager: UsageCacheManager + identityManager: IdentityManager serverAdapter?: ExpressAdapter }) { const predictionQueueName = `${QUEUE_NAME}-prediction` @@ -161,7 +164,8 @@ export class QueueManager { telemetry, cachePool, appDataSource, - usageCacheManager + usageCacheManager, + identityManager }) this.registerQueue('schedule', scheduleQueue) diff --git a/packages/server/src/queue/ScheduleBeat.ts b/packages/server/src/queue/ScheduleBeat.ts index f58c9f3741f..03479fc80c5 100644 --- a/packages/server/src/queue/ScheduleBeat.ts +++ b/packages/server/src/queue/ScheduleBeat.ts @@ -210,7 +210,8 @@ export class ScheduleBeat { telemetry: appServer.telemetry, cachePool: appServer.cachePool, usageCacheManager: appServer.usageCacheManager, - sseStreamer: appServer.sseStreamer + sseStreamer: appServer.sseStreamer, + identityManager: appServer.identityManager } await executeScheduleJob(ctx, scheduleRecordId, { diff --git a/packages/server/src/queue/ScheduleExecutor.test.ts b/packages/server/src/queue/ScheduleExecutor.test.ts index 8c1ecbf64ec..7828047b81b 100644 --- a/packages/server/src/queue/ScheduleExecutor.test.ts +++ b/packages/server/src/queue/ScheduleExecutor.test.ts @@ -40,6 +40,11 @@ jest.mock('../Interface', () => ({}), { virtual: true }) jest.mock('../utils/telemetry', () => ({ Telemetry: class Telemetry {} })) jest.mock('../CachePool', () => ({ CachePool: class CachePool {} })) jest.mock('../UsageCacheManager', () => ({ UsageCacheManager: class UsageCacheManager {} })) +jest.mock('../IdentityManager', () => ({ IdentityManager: class IdentityManager {} })) +jest.mock('../utils/quotaUsage', () => ({ + checkPredictions: jest.fn(), + updatePredictionsUsage: jest.fn() +})) // ─── Imports (after mocks) ──────────────────────────────────────────────────── @@ -79,6 +84,8 @@ const makeChatFlow = (overrides: Record = {}) => ({ // ─── Test fixture setup ─────────────────────────────────────────────────────── let mockFindOneBy: jest.Mock +let mockWorkspaceFindOneBy: jest.Mock +let mockOrgFindOneBy: jest.Mock let mockAppDataSource: { getRepository: jest.Mock } let mockCtx: any @@ -86,8 +93,15 @@ beforeEach(() => { jest.clearAllMocks() mockFindOneBy = jest.fn() + mockWorkspaceFindOneBy = jest.fn().mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' }) + mockOrgFindOneBy = jest.fn().mockResolvedValue({ id: 'org-1', subscriptionId: 'sub-1' }) mockAppDataSource = { - getRepository: jest.fn().mockReturnValue({ findOneBy: mockFindOneBy }) + getRepository: jest.fn().mockImplementation((Entity: any) => { + const name = Entity?.name ?? '' + if (name === 'Workspace') return { findOneBy: mockWorkspaceFindOneBy } + if (name === 'Organization') return { findOneBy: mockOrgFindOneBy } + return { findOneBy: mockFindOneBy } + }) } mockCtx = { appDataSource: mockAppDataSource, @@ -95,7 +109,8 @@ beforeEach(() => { telemetry: {}, cachePool: {}, usageCacheManager: {}, - sseStreamer: {} + sseStreamer: {}, + identityManager: { getProductIdFromSubscription: jest.fn().mockResolvedValue('prod-1') } } ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(true) ;(scheduleService.createTriggerLog as jest.Mock).mockResolvedValue({ id: 'log-1' }) diff --git a/packages/server/src/queue/ScheduleExecutor.ts b/packages/server/src/queue/ScheduleExecutor.ts index 7cf70377e16..42bb1562cb4 100644 --- a/packages/server/src/queue/ScheduleExecutor.ts +++ b/packages/server/src/queue/ScheduleExecutor.ts @@ -12,13 +12,17 @@ import { IServerSideEventStreamer } from 'flowise-components' import { ScheduleRecord, ScheduleTriggerType } from '../database/entities/ScheduleRecord' import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' import { ChatFlow } from '../database/entities/ChatFlow' +import { Workspace } from '../enterprise/database/entities/workspace.entity' +import { Organization } from '../enterprise/database/entities/organization.entity' import { executeAgentFlow } from '../utils/buildAgentflow' +import { checkPredictions, updatePredictionsUsage } from '../utils/quotaUsage' import scheduleService from '../services/schedule' import { Telemetry } from '../utils/telemetry' import { CachePool } from '../CachePool' import { UsageCacheManager } from '../UsageCacheManager' import { v4 as uuidv4 } from 'uuid' import logger from '../utils/logger' +import { IdentityManager } from '../IdentityManager' // ─── Types ───────────────────────────────────────────────────────────────────── @@ -33,6 +37,7 @@ export interface ScheduleExecutionContext { cachePool: CachePool usageCacheManager: UsageCacheManager sseStreamer: IServerSideEventStreamer + identityManager: IdentityManager } /** @@ -137,7 +142,7 @@ export async function executeScheduleJob( // ─── Internal ────────────────────────────────────────────────────────────────── async function _executeAgentflow(ctx: ScheduleExecutionContext, record: ScheduleRecord, scheduledAt: Date): Promise { - const { appDataSource, componentNodes, telemetry, cachePool, usageCacheManager, sseStreamer } = ctx + const { appDataSource, componentNodes, telemetry, cachePool, usageCacheManager, sseStreamer, identityManager } = ctx const startTime = Date.now() const log = await scheduleService.createTriggerLog({ @@ -153,7 +158,21 @@ async function _executeAgentflow(ctx: ScheduleExecutionContext, record: Schedule try { const chatflow = await appDataSource.getRepository(ChatFlow).findOneBy({ id: record.targetId }) if (!chatflow) throw new Error(`ChatFlow ${record.targetId} not found`) - if (chatflow.type !== 'AGENTFLOW') throw new Error(`ChatFlow ${record.targetId} is not of type AGENTFLOW`) + const isAgentFlow = chatflow.type === 'AGENTFLOW' + if (!isAgentFlow) throw new Error(`ChatFlow ${record.targetId} is not of type AGENTFLOW`) + + const workspaceId = chatflow.workspaceId ?? record.workspaceId + + const workspace = await appDataSource.getRepository(Workspace).findOneBy({ id: workspaceId }) + if (!workspace) throw new Error(`Workspace ${workspaceId} not found`) + const org = await appDataSource.getRepository(Organization).findOneBy({ id: workspace.organizationId }) + if (!org) throw new Error(`Organization ${workspace.organizationId} not found`) + + const orgId = org.id + const subscriptionId = org.subscriptionId as string + const productId = await identityManager.getProductIdFromSubscription(subscriptionId) + + await checkPredictions(org.id, subscriptionId, usageCacheManager) const chatId = uuidv4() const incomingInput: IncomingAgentflowInput = { @@ -177,10 +196,10 @@ async function _executeAgentflow(ctx: ScheduleExecutionContext, record: Schedule uploadedFilesContent: '', fileUploads: [], isTool: true, - workspaceId: chatflow.workspaceId ?? record.workspaceId, - orgId: '', - subscriptionId: '', - productId: '' + workspaceId, + orgId, + subscriptionId, + productId }) const elapsedTimeMs = Date.now() - startTime @@ -193,6 +212,7 @@ async function _executeAgentflow(ctx: ScheduleExecutionContext, record: Schedule executionId }) + await updatePredictionsUsage(orgId, subscriptionId, workspaceId, usageCacheManager) await scheduleService.updateScheduleAfterRun(appDataSource, record.id, record.cronExpression, record.timezone ?? 'UTC') logger.debug(`[ScheduleExecutor]: Completed schedule ${record.id} (${elapsedTimeMs}ms)`) return result diff --git a/packages/server/src/queue/ScheduleQueue.test.ts b/packages/server/src/queue/ScheduleQueue.test.ts index 1c690ca2550..3041326f20b 100644 --- a/packages/server/src/queue/ScheduleQueue.test.ts +++ b/packages/server/src/queue/ScheduleQueue.test.ts @@ -56,7 +56,8 @@ const OPTIONS = { telemetry: {} as any, cachePool: {} as any, componentNodes: {} as any, - usageCacheManager: {} as any + usageCacheManager: {} as any, + identityManager: {} as any } function makeQueue(name = 'schedule') { diff --git a/packages/server/src/queue/ScheduleQueue.ts b/packages/server/src/queue/ScheduleQueue.ts index 73ebeb86ca5..6514a859754 100644 --- a/packages/server/src/queue/ScheduleQueue.ts +++ b/packages/server/src/queue/ScheduleQueue.ts @@ -10,6 +10,7 @@ import { CachePool } from '../CachePool' import { UsageCacheManager } from '../UsageCacheManager' import { RedisEventPublisher } from './RedisEventPublisher' import { executeScheduleJob } from './ScheduleExecutor' +import { IdentityManager } from '../IdentityManager' interface ScheduleQueueOptions { appDataSource: DataSource @@ -17,6 +18,7 @@ interface ScheduleQueueOptions { cachePool: CachePool componentNodes: IComponentNodes usageCacheManager: UsageCacheManager + identityManager: IdentityManager } interface ScheduleAgentflowJobData { @@ -35,6 +37,7 @@ export class ScheduleQueue extends BaseQueue { private cachePool: CachePool private appDataSource: DataSource private usageCacheManager: UsageCacheManager + private identityManager: IdentityManager private redisPublisher: RedisEventPublisher private queueName: string @@ -46,6 +49,7 @@ export class ScheduleQueue extends BaseQueue { this.cachePool = options.cachePool this.appDataSource = options.appDataSource this.usageCacheManager = options.usageCacheManager + this.identityManager = options.identityManager this.redisPublisher = new RedisEventPublisher() // sseStreamer for agentflow execution results this.redisPublisher.connect() } @@ -73,7 +77,8 @@ export class ScheduleQueue extends BaseQueue { telemetry: this.telemetry, cachePool: this.cachePool, usageCacheManager: this.usageCacheManager, - sseStreamer: this.redisPublisher + sseStreamer: this.redisPublisher, + identityManager: this.identityManager } return executeScheduleJob(ctx, scheduleRecordId, { diff --git a/packages/server/src/services/schedule/utils.test.ts b/packages/server/src/services/schedule/utils.test.ts index 750326100fd..542d7015aac 100644 --- a/packages/server/src/services/schedule/utils.test.ts +++ b/packages/server/src/services/schedule/utils.test.ts @@ -29,8 +29,10 @@ describe('validateCronExpression', () => { expect(validateCronExpression('0,30 * * * *')).toEqual({ valid: true }) }) - it('accepts 6-field cron with seconds', () => { - expect(validateCronExpression('0 * * * * *')).toEqual({ valid: true }) + it('rejects 6-field cron with seconds (not supported)', () => { + const result = validateCronExpression('0 * * * * *') + expect(result.valid).toBe(false) + expect(result.error).toMatch(/5 fields/) }) it('accepts step on a range base', () => { diff --git a/packages/server/src/services/schedule/utils.ts b/packages/server/src/services/schedule/utils.ts index 0768dd39883..44d0b5cd098 100644 --- a/packages/server/src/services/schedule/utils.ts +++ b/packages/server/src/services/schedule/utils.ts @@ -19,10 +19,10 @@ export const validateCronExpression = (expression: string, timezone: string = 'U const trimmed = expression.trim() const fields = trimmed.split(/\s+/) - if (fields.length !== 5 && fields.length !== 6) { + if (fields.length !== 5) { return { valid: false, - error: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)' + error: 'Cron expression must have 5 fields (minute hour day month weekday)' } } @@ -73,17 +73,15 @@ export const validateCronExpression = (expression: string, timezone: string = 'U // Per-position field ranges [min, max]: minute hour day-of-month month day-of-week const fieldRanges: Array<[number, number]> = [ - [0, 59], // minutes (or seconds when 6-field) + [0, 59], // minutes [0, 23], // hours [1, 31], // day of month [1, 12], // month [0, 7] // day of week (0 and 7 both represent Sunday) ] - // For 6-field cron, prepend an extra seconds range (same as minutes: 0-59) - const ranges: Array<[number, number]> = fields.length === 6 ? [[0, 59], ...fieldRanges] : fieldRanges for (let i = 0; i < fields.length; i++) { - if (!validateCronField(fields[i], ranges[i][0], ranges[i][1])) { + if (!validateCronField(fields[i], fieldRanges[i][0], fieldRanges[i][1])) { return { valid: false, error: `Invalid cron field at position ${i + 1}: "${fields[i]}"` } } } @@ -131,16 +129,14 @@ interface _ParsedCronFields { /** Parse a cron expression once so fields can be reused across many date checks. */ function _parseCronFields(expression: string): _ParsedCronFields { const fields = expression.trim().split(/\s+/) - const offset = fields.length === 6 ? 1 : 0 return { - minuteField: fields[0 + offset], - hourField: fields[1 + offset], - domField: fields[2 + offset], - monthField: fields[3 + offset], - dowField: fields[4 + offset] + minuteField: fields[0], + hourField: fields[1], + domField: fields[2], + monthField: fields[3], + dowField: fields[4] } } - /** * Check whether a pre-parsed cron matches `date`, using a pre-built Intl.DateTimeFormat for TZ conversion. * Both `parsed` and `fmt` should be created once outside any hot loop. From 974f80fb12329ea7321cad93a4189b0d7ce399b4 Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Fri, 10 Apr 2026 22:07:09 +0700 Subject: [PATCH 08/15] feat: add support for MIN_SCHEDULE_INTERVAL_SECONDS in cron job configuration and validation --- docker/.env.example | 5 + docker/docker-compose-queue-prebuilt.yml | 3 + docker/docker-compose.yml | 3 + docker/worker/docker-compose.yml | 3 + packages/server/.env.example | 5 + packages/server/src/commands/base.ts | 5 +- .../src/services/schedule/utils.test.ts | 122 +++++++++++++- .../server/src/services/schedule/utils.ts | 159 +++++++++++++++--- 8 files changed, 278 insertions(+), 27 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index e1832e6350f..bba447a88af 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -203,3 +203,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # PUPPETEER_EXECUTABLE_FILE_PATH='C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' # PLAYWRIGHT_EXECUTABLE_FILE_PATH='C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' + +############################################################################################################ +########################################### SCHEDULE ############################################### +############################################################################################################ +# MIN_SCHEDULE_INTERVAL_SECONDS=60 \ No newline at end of file diff --git a/docker/docker-compose-queue-prebuilt.yml b/docker/docker-compose-queue-prebuilt.yml index 36f07739798..fe8a4b81fae 100644 --- a/docker/docker-compose-queue-prebuilt.yml +++ b/docker/docker-compose-queue-prebuilt.yml @@ -155,6 +155,9 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + + # SCHEDULE + - MIN_SCHEDULE_INTERVAL_SECONDS=${MIN_SCHEDULE_INTERVAL_SECONDS} healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:${PORT:-3000}/api/v1/ping'] interval: 10s diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index cb7d870dae8..99fa4200c5b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -140,6 +140,9 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + + # SCHEDULE + - MIN_SCHEDULE_INTERVAL_SECONDS=${MIN_SCHEDULE_INTERVAL_SECONDS} ports: - '${PORT}:${PORT}' healthcheck: diff --git a/docker/worker/docker-compose.yml b/docker/worker/docker-compose.yml index e65916a3373..161b31bf4f3 100644 --- a/docker/worker/docker-compose.yml +++ b/docker/worker/docker-compose.yml @@ -140,6 +140,9 @@ services: - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} - TRUST_PROXY=${TRUST_PROXY} + + # SCHEDULE + - MIN_SCHEDULE_INTERVAL_SECONDS=${MIN_SCHEDULE_INTERVAL_SECONDS} ports: - '${WORKER_PORT}:${WORKER_PORT}' healthcheck: diff --git a/packages/server/.env.example b/packages/server/.env.example index 5b932c3fa8a..290201982c7 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -202,3 +202,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # PUPPETEER_EXECUTABLE_FILE_PATH='C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' # PLAYWRIGHT_EXECUTABLE_FILE_PATH='C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' + +############################################################################################################ +########################################### SCHEDULE ############################################### +############################################################################################################ +# MIN_SCHEDULE_INTERVAL_SECONDS=60 diff --git a/packages/server/src/commands/base.ts b/packages/server/src/commands/base.ts index 6de87fa4d03..337b1f64c90 100644 --- a/packages/server/src/commands/base.ts +++ b/packages/server/src/commands/base.ts @@ -155,7 +155,10 @@ export abstract class BaseCommand extends Command { // Document Loaders PUPPETEER_EXECUTABLE_FILE_PATH: Flags.string(), - PLAYWRIGHT_EXECUTABLE_FILE_PATH: Flags.string() + PLAYWRIGHT_EXECUTABLE_FILE_PATH: Flags.string(), + + // Schedule + MIN_SCHEDULE_INTERVAL_SECONDS: Flags.string() } protected async stopProcess() { diff --git a/packages/server/src/services/schedule/utils.test.ts b/packages/server/src/services/schedule/utils.test.ts index 542d7015aac..4577730cabb 100644 --- a/packages/server/src/services/schedule/utils.test.ts +++ b/packages/server/src/services/schedule/utils.test.ts @@ -29,10 +29,9 @@ describe('validateCronExpression', () => { expect(validateCronExpression('0,30 * * * *')).toEqual({ valid: true }) }) - it('rejects 6-field cron with seconds (not supported)', () => { + it('accepts 6-field cron with seconds', () => { const result = validateCronExpression('0 * * * * *') - expect(result.valid).toBe(false) - expect(result.error).toMatch(/5 fields/) + expect(result.valid).toBe(true) }) it('accepts step on a range base', () => { @@ -118,6 +117,56 @@ describe('validateCronExpression', () => { expect(result.error).toMatch(/Invalid timezone/) }) }) + + describe('minIntervalSeconds (60) β€” 6-field cron seconds validation', () => { + it('rejects 6-field cron firing every second (*/1) with default minInterval', () => { + const result = validateCronExpression('* * * * * *') + expect(result.valid).toBe(false) + expect(result.error).toMatch(/below the minimum interval/) + }) + + it('accepts 6-field cron with seconds step >= default minInterval 10', () => { + const result = validateCronExpression('*/15 * * * * *', 'UTC', 10) + expect(result.valid).toBe(true) + }) + + it('rejects 6-field cron with seconds step < minIntervalSeconds', () => { + // */10 fires every 10s, minInterval = 30 + const result = validateCronExpression('*/10 * * * * *', 'UTC', 30) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/fires every 10s/) + }) + + it('accepts 6-field cron with seconds step >= minIntervalSeconds', () => { + // */30 fires every 30s, minInterval = 30 + const result = validateCronExpression('*/30 * * * * *', 'UTC', 30) + expect(result.valid).toBe(true) + }) + + it('rejects comma-list seconds with small gap', () => { + // 0,5 β†’ gap = 5s, wrap-around gap = 55s β†’ min = 5s + const result = validateCronExpression('0,5 * * * * *', 'UTC', 10) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/fires every 5s/) + }) + + it('accepts single-second 6-field cron (fires once per minute)', () => { + const result = validateCronExpression('0 * * * * *', 'UTC', 60) + expect(result.valid).toBe(true) + }) + + it('accounts for wrap-around gap in seconds', () => { + // 0,50 β†’ gaps: 50s and wrap-around 10s β†’ min = 10s + const result = validateCronExpression('0,50 * * * * *', 'UTC', 15) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/fires every 10s/) + }) + + it('accepts when minIntervalSeconds is 1 (no restriction)', () => { + const result = validateCronExpression('* * * * * *', 'UTC', 1) + expect(result.valid).toBe(true) + }) + }) }) // ─── computeNextRunAt ───────────────────────────────────────────────────────── @@ -187,6 +236,73 @@ describe('computeNextRunAt', () => { expect(next!.getUTCMinutes()).toBe(15) expect(next!.getUTCSeconds()).toBe(0) }) + + // ── 6-field cron (seconds) ───────────────────────────────────────── + + it('supports 6-field cron: */15 fires at next 15-second boundary', () => { + const ref = new Date('2025-01-01T12:00:10Z') + const next = computeNextRunAt('*/15 * * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.toISOString()).toBe('2025-01-01T12:00:15.000Z') + }) + + it('supports 6-field cron: */30 fires at next 30-second boundary', () => { + const ref = new Date('2025-01-01T12:00:05Z') + const next = computeNextRunAt('*/30 * * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.toISOString()).toBe('2025-01-01T12:00:30.000Z') + }) + + it('supports 6-field cron: rolls to next minute when no matching second remains', () => { + // */30 matches 0 and 30 β€” ref at :45 should roll into next minute at :00 + const ref = new Date('2025-01-01T12:00:45Z') + const next = computeNextRunAt('*/30 * * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.toISOString()).toBe('2025-01-01T12:01:00.000Z') + }) + + it('supports 6-field cron: specific second value', () => { + // Fire at second 20 of every minute + const ref = new Date('2025-01-01T12:00:10Z') + const next = computeNextRunAt('20 * * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCSeconds()).toBe(20) + expect(next!.getUTCMinutes()).toBe(0) + }) + + it('supports 6-field cron: specific second + specific minute', () => { + // Fire at second 30, minute 15 of every hour + const ref = new Date('2025-01-01T12:00:00Z') + const next = computeNextRunAt('30 15 * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCHours()).toBe(12) + expect(next!.getUTCMinutes()).toBe(15) + expect(next!.getUTCSeconds()).toBe(30) + }) + + it('supports 6-field cron: comma-separated seconds', () => { + // Fire at seconds 0 and 30 + const ref = new Date('2025-01-01T12:00:10Z') + const next = computeNextRunAt('0,30 * * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCSeconds()).toBe(30) + }) + + it('supports 6-field cron: seconds with timezone', () => { + // Fire at second 0, minute 0, hour 9 in New York time + const ref = new Date('2025-06-15T12:59:50Z') // 08:59:50 NY + const next = computeNextRunAt('0 0 9 * * *', 'America/New_York', ref) + expect(next).not.toBeNull() + // 09:00:00 NY = 13:00:00 UTC (EDT = UTC-4) + expect(next!.toISOString()).toBe('2025-06-15T13:00:00.000Z') + }) + + it('returns milliseconds-zeroed output for 6-field cron', () => { + const ref = new Date('2025-01-01T00:00:00.500Z') + const next = computeNextRunAt('*/15 * * * * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCMilliseconds()).toBe(0) + }) }) // ─── validateVisualPickerFields ─────────────────────────────────────────────── diff --git a/packages/server/src/services/schedule/utils.ts b/packages/server/src/services/schedule/utils.ts index 44d0b5cd098..b02564a4238 100644 --- a/packages/server/src/services/schedule/utils.ts +++ b/packages/server/src/services/schedule/utils.ts @@ -5,13 +5,19 @@ // ─── Cron expression validation ────────────────────────────────────────────── +const MIN_SCHEDULE_INTERVAL_SECONDS = Math.max(1, parseInt(process.env.MIN_SCHEDULE_INTERVAL_SECONDS || '60', 10) || 60) + /** * Validates a cron expression and returns parsed info. * Uses a lightweight regex-based check without external dependencies. * - * Supports standard 5-field cron: minute hour day month weekday + * Supports extended 6-field cron: second minute hour day month weekday */ -export const validateCronExpression = (expression: string, timezone: string = 'UTC'): { valid: boolean; error?: string } => { +export const validateCronExpression = ( + expression: string, + timezone: string = 'UTC', + minIntervalSeconds: number = MIN_SCHEDULE_INTERVAL_SECONDS +): { valid: boolean; error?: string } => { if (!expression || typeof expression !== 'string') { return { valid: false, error: 'Cron expression must be a non-empty string' } } @@ -19,10 +25,10 @@ export const validateCronExpression = (expression: string, timezone: string = 'U const trimmed = expression.trim() const fields = trimmed.split(/\s+/) - if (fields.length !== 5) { + if (fields.length !== 5 && fields.length !== 6) { return { valid: false, - error: 'Cron expression must have 5 fields (minute hour day month weekday)' + error: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)' } } @@ -73,19 +79,87 @@ export const validateCronExpression = (expression: string, timezone: string = 'U // Per-position field ranges [min, max]: minute hour day-of-month month day-of-week const fieldRanges: Array<[number, number]> = [ - [0, 59], // minutes + [0, 59], // minutes (or seconds when 6-field) [0, 23], // hours [1, 31], // day of month [1, 12], // month [0, 7] // day of week (0 and 7 both represent Sunday) ] + // For 6-field cron, prepend an extra seconds range (same as minutes: 0-59) + const ranges: Array<[number, number]> = fields.length === 6 ? [[0, 59], ...fieldRanges] : fieldRanges for (let i = 0; i < fields.length; i++) { - if (!validateCronField(fields[i], fieldRanges[i][0], fieldRanges[i][1])) { + if (!validateCronField(fields[i], ranges[i][0], ranges[i][1])) { return { valid: false, error: `Invalid cron field at position ${i + 1}: "${fields[i]}"` } } } + // For 6-field cron, verify the seconds field doesn't cause firing more frequently than minIntervalSeconds + if (fields.length === 6 && minIntervalSeconds > 1) { + const secondsField = fields[0] + // Expand the seconds field to all matching values in [0, 59] + const matchingSeconds: number[] = [] + const seen = new Set() + for (const part of secondsField.split(',')) { + if (part.includes('/')) { + const [rangeStr, stepStr] = part.split('/') + const step = parseInt(stepStr, 10) + let start: number, end: number + if (rangeStr === '*') { + start = 0 + end = 59 + } else if (rangeStr.includes('-')) { + ;[start, end] = rangeStr.split('-').map(Number) + } else { + start = parseInt(rangeStr, 10) + end = 59 + } + for (let v = start; v <= end; v += step) { + if (!seen.has(v)) { + seen.add(v) + matchingSeconds.push(v) + } + } + } else if (part === '*') { + for (let v = 0; v <= 59; v++) { + if (!seen.has(v)) { + seen.add(v) + matchingSeconds.push(v) + } + } + } else if (part.includes('-')) { + const [s, e] = part.split('-').map(Number) + for (let v = s; v <= e; v++) { + if (!seen.has(v)) { + seen.add(v) + matchingSeconds.push(v) + } + } + } else { + const v = parseInt(part, 10) + if (!seen.has(v)) { + seen.add(v) + matchingSeconds.push(v) + } + } + } + matchingSeconds.sort((a, b) => a - b) + + if (matchingSeconds.length > 1) { + // Compute the minimum gap between consecutive matching seconds (including wrap-around) + let minGap = 60 - matchingSeconds[matchingSeconds.length - 1] + matchingSeconds[0] + for (let i = 1; i < matchingSeconds.length; i++) { + minGap = Math.min(minGap, matchingSeconds[i] - matchingSeconds[i - 1]) + } + if (minGap < minIntervalSeconds) { + return { + valid: false, + error: `Cron expression fires every ${minGap}s which is below the minimum interval of ${minIntervalSeconds}s` + } + } + } + } + return { valid: true } } @@ -129,14 +203,16 @@ interface _ParsedCronFields { /** Parse a cron expression once so fields can be reused across many date checks. */ function _parseCronFields(expression: string): _ParsedCronFields { const fields = expression.trim().split(/\s+/) + const offset = fields.length === 6 ? 1 : 0 return { - minuteField: fields[0], - hourField: fields[1], - domField: fields[2], - monthField: fields[3], - dowField: fields[4] + minuteField: fields[0 + offset], + hourField: fields[1 + offset], + domField: fields[2 + offset], + monthField: fields[3 + offset], + dowField: fields[4 + offset] } } + /** * Check whether a pre-parsed cron matches `date`, using a pre-built Intl.DateTimeFormat for TZ conversion. * Both `parsed` and `fmt` should be created once outside any hot loop. @@ -172,19 +248,21 @@ function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.Dat /** * Computes the next Date after `after` (defaults to now) when the cron expression will fire. - * Searches minute-by-minute, up to 1 year ahead. Returns null if no match is found. + * + * For 5-field cron expressions, searches minute-by-minute up to 1 year ahead. + * + * For 6-field cron expressions (with seconds), finds the next matching minute first, + * then resolves the exact second within that minute. This supports sub-minute schedules + * such as every 15 or 30 seconds (default minimum safe threshold: 60 seconds). * * The Intl.DateTimeFormat instance and parsed cron fields are created once before the loop * to avoid repeated allocations on every iteration. - * - * For 6-field cron expressions with seconds, the search still only considers minute-level matches and ignores the seconds field (i.e. treats it as if it were "0"). - * This is because the scheduler only triggers at minute-level granularity, so the seconds field is not relevant for computing the next run time. */ export const computeNextRunAt = (cronExpression: string, timezone: string = 'UTC', after?: Date): Date | null => { + const fields = cronExpression.trim().split(/\s+/) + const hasSeconds = fields.length === 6 + const start = new Date(after ? after.getTime() : Date.now()) - // Snap to start of next minute so we never return the current minute - start.setSeconds(0, 0) - start.setMinutes(start.getMinutes() + 1) // Hoist allocations outside the loop const parsed = _parseCronFields(cronExpression) @@ -199,11 +277,46 @@ export const computeNextRunAt = (cronExpression: string, timezone: string = 'UTC hour12: false }) - const maxIterations = 60 * 24 * 366 // up to ~1 year of minutes - for (let i = 0; i < maxIterations; i++) { - const candidate = new Date(start.getTime() + i * 60_000) - if (_cronMatchesParsed(parsed, candidate, fmt)) { - return candidate + if (!hasSeconds) { + // ── 5-field cron: minute-level search ────────────────────────────── + start.setSeconds(0, 0) + start.setMinutes(start.getMinutes() + 1) + + const maxIterations = 60 * 24 * 366 // up to ~1 year of minutes + for (let i = 0; i < maxIterations; i++) { + const candidate = new Date(start.getTime() + i * 60_000) + if (_cronMatchesParsed(parsed, candidate, fmt)) { + return candidate + } + } + return null + } + + // ── 6-field cron: second-level search ────────────────────────────────── + const secondField = fields[0] + + // Snap to the start of the next second + start.setMilliseconds(0) + start.setSeconds(start.getSeconds() + 1) + + // Determine the first minute boundary and the second offset within it + const firstMinuteMs = start.getTime() - (start.getTime() % 60_000) + const firstSecondOffset = Math.round((start.getTime() - firstMinuteMs) / 1000) + + const maxMinuteIterations = 60 * 24 * 366 // up to ~1 year of minutes + for (let i = 0; i < maxMinuteIterations; i++) { + const minuteMs = firstMinuteMs + i * 60_000 + const minuteDate = new Date(minuteMs) + + if (!_cronMatchesParsed(parsed, minuteDate, fmt)) continue + + // This minute matches β€” find the first matching second + // For the first iteration, skip seconds before our start time + const secStart = i === 0 ? firstSecondOffset : 0 + for (let s = secStart; s <= 59; s++) { + if (_matchCronField(secondField, s, 0)) { + return new Date(minuteMs + s * 1000) + } } } return null From 311020356ded1e363dd92dc0b6b6f15c080d883f Mon Sep 17 00:00:00 2001 From: Jackie Chui Date: Mon, 20 Apr 2026 16:17:05 -0700 Subject: [PATCH 09/15] fixed broken tests --- packages/server/__mocks__/typeorm.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/server/__mocks__/typeorm.ts b/packages/server/__mocks__/typeorm.ts index b75d11cb920..ffcdfa36558 100644 --- a/packages/server/__mocks__/typeorm.ts +++ b/packages/server/__mocks__/typeorm.ts @@ -7,6 +7,14 @@ const decorator = (): (() => void) => () => {} +// Lightweight FindOperator-like factories. Real TypeORM returns instances of +// FindOperator with `type` and `value` fields; tests only assert on these, +// so a plain object with the same shape is sufficient. +const findOperator = (type: string) => (value: unknown, secondValue?: unknown) => ({ + type, + value: secondValue === undefined ? value : [value, secondValue] +}) + module.exports = { Column: decorator, Entity: decorator, @@ -20,5 +28,9 @@ module.exports = { OneToOne: decorator, JoinColumn: decorator, Unique: decorator, - DataSource: jest.fn() + DataSource: jest.fn(), + In: findOperator('in'), + Between: findOperator('between'), + MoreThanOrEqual: findOperator('moreThanOrEqual'), + LessThanOrEqual: findOperator('lessThanOrEqual') } From 5b63167f1750d45542d43776cdd288237739123f Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 23 Apr 2026 12:36:09 +0100 Subject: [PATCH 10/15] UI UX fixes for scheduler in dark mode --- .../components/nodes/agentflow/Start/Start.ts | 1 + packages/server/src/commands/worker.ts | 2 +- .../src/services/chatflows/index.test.ts | 4 +- packages/ui/src/assets/scss/style.scss | 10 ++ .../ui/src/ui-component/picker/DatePicker.jsx | 13 +- .../ui/src/ui-component/picker/TimePicker.jsx | 15 ++- .../src/views/agentflowsv2/AgentFlowNode.jsx | 8 +- packages/ui/src/views/canvas/CanvasHeader.jsx | 121 +++++++++++------- 8 files changed, 114 insertions(+), 60 deletions(-) diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 73f0c550183..d6e52dde59b 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -148,6 +148,7 @@ class Start_Agentflow implements INode { description: 'Use a cron expression to define the schedule' } ], + default: 'visualPicker', show: { startInputType: 'scheduleInput' } diff --git a/packages/server/src/commands/worker.ts b/packages/server/src/commands/worker.ts index 5a010c947dd..056de86bdd6 100644 --- a/packages/server/src/commands/worker.ts +++ b/packages/server/src/commands/worker.ts @@ -121,7 +121,7 @@ export default class Worker extends BaseCommand { await upsertWorker.close() const scheduleWorker = queueManager.getQueue('schedule').getWorker() - logger.info(`Shutting down Flowise Schedule Worker...`) + logger.info(`Shutting down Flowise Schedule Worker ${this.scheduleWorkerId}...`) await scheduleWorker.close() } catch (error) { logger.error('There was an error shutting down Flowise Worker...', error) diff --git a/packages/server/src/services/chatflows/index.test.ts b/packages/server/src/services/chatflows/index.test.ts index b0e7e81ef9d..950484824ee 100644 --- a/packages/server/src/services/chatflows/index.test.ts +++ b/packages/server/src/services/chatflows/index.test.ts @@ -115,13 +115,11 @@ jest.mock('http-status-codes', () => ({ import chatflowsService from './index' import scheduleService from '../../services/schedule' import { ScheduleBeat } from '../../queue/ScheduleBeat' -import { containsBase64File, updateFlowDataWithFilePaths } from '../../utils/fileRepository' +import { containsBase64File } from '../../utils/fileRepository' import { EnumChatflowType } from '../../database/entities/ChatFlow' import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' const mockContainsBase64File = containsBase64File as jest.Mock -const mockUpdateFlowDataWithFilePaths = updateFlowDataWithFilePaths as jest.Mock -const mockOnScheduleChanged = ScheduleBeat.getInstance().onScheduleChanged as jest.Mock const mockCreateOrUpdateSchedule = scheduleService.createOrUpdateSchedule as jest.Mock const mockDeleteScheduleForTarget = scheduleService.deleteScheduleForTarget as jest.Mock const mockResolveScheduleCron = scheduleService.resolveScheduleCron as jest.Mock diff --git a/packages/ui/src/assets/scss/style.scss b/packages/ui/src/assets/scss/style.scss index bda0dbb7bea..5fb451cedb7 100644 --- a/packages/ui/src/assets/scss/style.scss +++ b/packages/ui/src/assets/scss/style.scss @@ -212,6 +212,16 @@ animation: spin 1s linear infinite; } +// ==============================|| NATIVE DATE/TIME PICKER (DARK) ||============================== // + +.picker-dark input { + color-scheme: dark; +} + +.picker-dark input::-webkit-calendar-picker-indicator { + cursor: pointer; +} + @keyframes spin { from { transform: rotate(0deg); diff --git a/packages/ui/src/ui-component/picker/DatePicker.jsx b/packages/ui/src/ui-component/picker/DatePicker.jsx index 9d549978f21..3d9ba8f6b37 100644 --- a/packages/ui/src/ui-component/picker/DatePicker.jsx +++ b/packages/ui/src/ui-component/picker/DatePicker.jsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react' import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' import { Box, TextField } from '@mui/material' import { useTheme } from '@mui/material/styles' export const DatePicker = ({ value, onChange, disabled = false, placeholder = 'YYYY-MM-DD' }) => { const theme = useTheme() + const isDark = useSelector((state) => state.customization?.isDarkMode) // Normalise to "YYYY-MM-DD" for the native date input const toDateString = (val) => { @@ -28,7 +30,7 @@ export const DatePicker = ({ value, onChange, disabled = false, placeholder = 'Y } return ( - + diff --git a/packages/ui/src/ui-component/picker/TimePicker.jsx b/packages/ui/src/ui-component/picker/TimePicker.jsx index e072166b1ed..15a9de3c0c8 100644 --- a/packages/ui/src/ui-component/picker/TimePicker.jsx +++ b/packages/ui/src/ui-component/picker/TimePicker.jsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react' import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' import { Box, TextField } from '@mui/material' import { useTheme } from '@mui/material/styles' export const TimePicker = ({ value, onChange, disabled = false, placeholder = '09:00' }) => { const theme = useTheme() + const isDark = useSelector((state) => state.customization?.isDarkMode) const [timeValue, setTimeValue] = useState(value || '') useEffect(() => { @@ -18,7 +20,7 @@ export const TimePicker = ({ value, onChange, disabled = false, placeholder = '0 } return ( - + diff --git a/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx index 568df3e7ad4..4d1a932ffe4 100644 --- a/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx +++ b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx @@ -29,7 +29,7 @@ import { IconBrowserCheck, IconMessageCircle, IconClockHour4, - IconForms + IconListDetails } from '@tabler/icons-react' import StopCircleIcon from '@mui/icons-material/StopCircle' import CancelIcon from '@mui/icons-material/Cancel' @@ -408,7 +408,7 @@ const AgentFlowNode = ({ data }) => { const inputType = data.inputs.startInputType const iconMap = { chatInput: { icon: }, - formInput: { icon: }, + formInput: { icon: }, scheduleInput: { icon: } } const info = iconMap[inputType] @@ -422,9 +422,9 @@ const AgentFlowNode = ({ data }) => { : 'rgba(255, 255, 255, 0.9)', borderRadius: '16px', height: 22, - pl: 0.8, - pr: 1, + px: 0.9, display: 'flex', + justifyContent: 'center', alignItems: 'center', gap: 0.5 }} diff --git a/packages/ui/src/views/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx index 12020ef059d..b46ad35385c 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.jsx +++ b/packages/ui/src/views/canvas/CanvasHeader.jsx @@ -4,8 +4,8 @@ import { useSelector, useDispatch } from 'react-redux' import { useEffect, useMemo, useRef, useState } from 'react' // material-ui -import { useTheme } from '@mui/material/styles' -import { Avatar, Box, ButtonBase, Typography, Stack, TextField, Button, Tooltip } from '@mui/material' +import { useTheme, styled } from '@mui/material/styles' +import { Avatar, Box, ButtonBase, Typography, Stack, Switch, TextField, Button, Tooltip } from '@mui/material' // icons import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconCode } from '@tabler/icons-react' @@ -32,6 +32,68 @@ import { generateExportFlowData } from '@/utils/genericHelper' import { uiBaseURL } from '@/store/constant' import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, SET_CHATFLOW } from '@/store/actions' +// Clock icon (unchecked) and calendar-check icon (checked), mirroring MaterialUISwitch style +const clockIcon = `url('data:image/svg+xml;utf8,')` +const clockCheckIcon = `url('data:image/svg+xml;utf8,')` + +const ScheduleSwitch = styled(Switch)(({ theme }) => ({ + width: 62, + height: 34, + padding: 7, + '& .MuiSwitch-switchBase': { + margin: 1, + padding: 0, + transform: 'translateX(6px)', + '&.Mui-checked': { + color: '#fff', + transform: 'translateX(22px)', + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.success.dark + }, + '& .MuiSwitch-thumb:before': { + backgroundImage: clockCheckIcon + }, + '& + .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.success.light + } + } + }, + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.mode === 'dark' ? '#4a5662' : '#c8cdd3', + width: 32, + height: 32, + '&:before': { + content: "''", + position: 'absolute', + width: '100%', + height: '100%', + left: 0, + top: 0, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + backgroundImage: clockIcon, + opacity: 0.85 + } + }, + '& .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.mode === 'dark' ? '#3b4450' : '#dde1e6', + borderRadius: 20 / 2 + }, + '&.Mui-disabled .MuiSwitch-thumb, & .Mui-disabled .MuiSwitch-thumb': { + backgroundColor: theme.palette.mode === 'dark' ? '#2f3640' : '#e3e6ea' + }, + '&.Mui-disabled + .MuiSwitch-track, & .Mui-disabled + .MuiSwitch-track': { + backgroundColor: theme.palette.mode === 'dark' ? '#262b33' : '#eceef1', + opacity: 1 + } +})) + // ==============================|| CANVAS HEADER ||============================== // const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, handleDeleteFlow, handleLoadFlow }) => { @@ -445,7 +507,7 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, )} - + {chatflow?.id && isAgentflowV2 && isScheduleFlow && ( - - Schedule - - { - if (!scheduleCanEnable && !scheduleEnabled) return - handleToggleSchedule(!scheduleEnabled) - }} - sx={{ - position: 'relative', - display: 'inline-block', - width: 56, - height: 32, - borderRadius: '5px', - backgroundColor: scheduleEnabled ? theme.palette.success.main : theme.palette.grey[400], - transition: 'background-color 0.2s ease-in-out', - cursor: !scheduleCanEnable && !scheduleEnabled ? 'not-allowed' : 'pointer', - flexShrink: 0 - }} - > - - + handleToggleSchedule(e.target.checked)} + /> )} From 5abe295eb71308369cc09542e66f9913e6861b05 Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Thu, 23 Apr 2026 21:12:12 +0700 Subject: [PATCH 11/15] fix(schedule): remove unnecessary UUID generation in createOrUpdateSchedule --- packages/server/src/services/schedule/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts index 3283bbca242..e1c8ef4b051 100644 --- a/packages/server/src/services/schedule/index.ts +++ b/packages/server/src/services/schedule/index.ts @@ -101,7 +101,6 @@ const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise Date: Sat, 25 Apr 2026 00:38:34 +0100 Subject: [PATCH 12/15] adds required scheduleInputMode column with text/form/none variants, ChatType.SCHEDULED, trigger-logs endpoint + drawer UI, IANA timezone dropdown, and dark-mode UX polish. --- .../components/nodes/agentflow/Start/Start.ts | 158 ++++- packages/server/src/Interface.ts | 7 +- .../server/src/controllers/chatflows/index.ts | 29 +- .../src/database/entities/ScheduleRecord.ts | 11 +- .../1772000000000-AddScheduleEntities.ts | 2 + .../1772000000000-AddScheduleEntities.ts | 2 + .../1772000000000-AddScheduleEntities.ts | 2 + .../1772000000000-AddScheduleEntities.ts | 2 + packages/server/src/index.ts | 2 +- .../server/src/queue/ScheduleQueue.test.ts | 4 +- packages/server/src/queue/ScheduleQueue.ts | 2 +- packages/server/src/routes/chatflows/index.ts | 5 + .../{queue => schedule}/ScheduleBeat.test.ts | 6 +- .../src/{queue => schedule}/ScheduleBeat.ts | 4 +- .../ScheduleExecutor.test.ts | 119 +++- .../{queue => schedule}/ScheduleExecutor.ts | 34 +- .../src/services/chatflows/index.test.ts | 66 +- .../server/src/services/chatflows/index.ts | 40 +- .../src/services/export-import/index.ts | 2 + .../src/services/schedule/index.test.ts | 112 ++++ .../server/src/services/schedule/index.ts | 81 ++- .../src/services/schedule/utils.test.ts | 121 +++- .../server/src/services/schedule/utils.ts | 23 +- packages/ui/src/api/chatflows.js | 5 +- .../dialog/ViewMessagesDialog.jsx | 10 +- .../ui-component/input/suggestionOption.js | 11 +- packages/ui/src/views/agentflowsv2/Canvas.jsx | 15 +- packages/ui/src/views/canvas/CanvasHeader.jsx | 145 +++-- .../views/schedule/ScheduleHistoryDrawer.jsx | 606 ++++++++++++++++++ .../src/views/schedule/ScheduleHistoryFAB.jsx | 77 +++ 30 files changed, 1560 insertions(+), 143 deletions(-) rename packages/server/src/{queue => schedule}/ScheduleBeat.test.ts (99%) rename packages/server/src/{queue => schedule}/ScheduleBeat.ts (98%) rename packages/server/src/{queue => schedule}/ScheduleExecutor.test.ts (81%) rename packages/server/src/{queue => schedule}/ScheduleExecutor.ts (89%) create mode 100644 packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx create mode 100644 packages/ui/src/views/schedule/ScheduleHistoryFAB.jsx diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index d6e52dde59b..e2ab91c75a3 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -1,5 +1,35 @@ import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +const TIMEZONE_OPTIONS: { label: string; name: string }[] = (() => { + try { + const tzs: string[] = (Intl as any).supportedValuesOf?.('timeZone') ?? [] + if (Array.isArray(tzs) && tzs.length > 0) { + return [{ label: 'UTC', name: 'UTC' }, ...tzs.filter((t) => t !== 'UTC').map((t) => ({ label: t, name: t }))] + } + } catch { + /* fall through to curated fallback */ + } + return [ + { label: 'UTC', name: 'UTC' }, + { label: 'America/Los_Angeles', name: 'America/Los_Angeles' }, + { label: 'America/Denver', name: 'America/Denver' }, + { label: 'America/Chicago', name: 'America/Chicago' }, + { label: 'America/New_York', name: 'America/New_York' }, + { label: 'America/Sao_Paulo', name: 'America/Sao_Paulo' }, + { label: 'Europe/London', name: 'Europe/London' }, + { label: 'Europe/Paris', name: 'Europe/Paris' }, + { label: 'Europe/Berlin', name: 'Europe/Berlin' }, + { label: 'Africa/Cairo', name: 'Africa/Cairo' }, + { label: 'Asia/Dubai', name: 'Asia/Dubai' }, + { label: 'Asia/Kolkata', name: 'Asia/Kolkata' }, + { label: 'Asia/Singapore', name: 'Asia/Singapore' }, + { label: 'Asia/Shanghai', name: 'Asia/Shanghai' }, + { label: 'Asia/Tokyo', name: 'Asia/Tokyo' }, + { label: 'Australia/Sydney', name: 'Australia/Sydney' }, + { label: 'Pacific/Auckland', name: 'Pacific/Auckland' } + ] +})() + class Start_Agentflow implements INode { label: string name: string @@ -18,7 +48,7 @@ class Start_Agentflow implements INode { constructor() { this.label = 'Start' this.name = 'startAgentflow' - this.version = 1.2 + this.version = 1.3 this.type = 'Start' this.category = 'Agent Flows' this.description = 'Starting point of the agentflow' @@ -252,14 +282,42 @@ class Start_Agentflow implements INode { { label: 'Timezone', name: 'scheduleTimezone', - type: 'string', - placeholder: 'UTC', - description: 'IANA timezone name, e.g. America/New_York. Defaults to UTC.', + type: 'options', + options: TIMEZONE_OPTIONS, + default: 'UTC', + description: 'IANA timezone. Defaults to UTC.', optional: true, show: { startInputType: 'scheduleInput' } }, + { + label: 'Schedule Input Mode', + name: 'scheduleInputMode', + type: 'options', + description: 'How the schedule should invoke this flow on each fire.', + options: [ + { + label: 'Default Text Input', + name: 'text', + description: 'Pass a fixed text string as the question on every fire' + }, + { + label: 'Form Input', + name: 'form', + description: 'Pass default values for the form fields below on every fire' + }, + { + label: 'No Input', + name: 'none', + description: 'Fire with no input.' + } + ], + default: 'text', + show: { + startInputType: 'scheduleInput' + } + }, { label: 'Default Input', name: 'scheduleDefaultInput', @@ -268,7 +326,72 @@ class Start_Agentflow implements INode { description: 'Default question/input passed to the flow when it is triggered by the scheduler.', rows: 4, show: { - startInputType: 'scheduleInput' + startInputType: 'scheduleInput', + scheduleInputMode: 'text' + } + }, + { + label: 'Form Fields', + name: 'scheduleFormInputTypes', + description: 'Define the typed fields this scheduled flow receives on each fire.', + type: 'array', + show: { + startInputType: 'scheduleInput', + scheduleInputMode: 'form' + }, + array: [ + { + label: 'Type', + name: 'type', + type: 'options', + options: [ + { label: 'String', name: 'string' }, + { label: 'Number', name: 'number' }, + { label: 'Boolean', name: 'boolean' }, + { label: 'Options', name: 'options' } + ], + default: 'string' + }, + { + label: 'Label', + name: 'label', + type: 'string', + placeholder: 'Label for the input' + }, + { + label: 'Variable Name', + name: 'name', + type: 'string', + placeholder: 'Variable name for the input (must be camel case)', + description: 'Variable name must be camel case. For example: firstName, lastName, etc.' + }, + { + label: 'Add Options', + name: 'addOptions', + type: 'array', + show: { + 'scheduleFormInputTypes[$index].type': 'options' + }, + array: [ + { + label: 'Option', + name: 'option', + type: 'string' + } + ] + } + ] + }, + { + label: 'Default Form Values', + name: 'scheduleFormDefaults', + type: 'json', + description: + 'Default values for the form fields above, as a JSON object keyed by variable name. Example: { "team": "engineering", "metric": "p95" }', + optional: true, + show: { + startInputType: 'scheduleInput', + scheduleInputMode: 'form' } }, { @@ -360,10 +483,27 @@ class Start_Agentflow implements INode { } if (startInputType === 'scheduleInput') { - const defaultInput = nodeData.inputs?.scheduleDefaultInput as string - const effectiveInput = (typeof input === 'string' && input) || defaultInput || '' - inputData.question = effectiveInput - outputData.question = effectiveInput + const scheduleInputMode = (nodeData.inputs?.scheduleInputMode as string) || 'text' + if (scheduleInputMode === 'form') { + inputData.form = { + inputs: nodeData.inputs?.scheduleFormInputTypes + } + let form: any = input + if (options.agentflowRuntime?.form && Object.keys(options.agentflowRuntime.form).length) { + form = options.agentflowRuntime.form + } + outputData.form = form + } else if (scheduleInputMode === 'none') { + // Single-space sentinel matches the engine's "no input" fallback at buildAgentflow.ts:2247 + // and avoids downstream Agent nodes filtering the user message and producing an empty messages[]. + inputData.question = ' ' + outputData.question = ' ' + } else { + const defaultInput = nodeData.inputs?.scheduleDefaultInput as string + const effectiveInput = (typeof input === 'string' && input) || defaultInput || '' + inputData.question = effectiveInput + outputData.question = effectiveInput + } } if (startEphemeralMemory) { diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 4a43d351a34..abb680f407f 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -31,7 +31,8 @@ export enum ChatType { INTERNAL = 'INTERNAL', EXTERNAL = 'EXTERNAL', EVALUATION = 'EVALUATION', - MCP = 'MCP' + MCP = 'MCP', + SCHEDULED = 'SCHEDULED' } export enum ChatMessageRatingType { @@ -183,6 +184,8 @@ export interface IExecution { workspaceId: string } +export type ScheduleInputMode = 'text' | 'form' | 'none' + export interface IScheduleRecord { id: string triggerType: string @@ -191,7 +194,9 @@ export interface IScheduleRecord { cronExpression: string timezone: string enabled: boolean + scheduleInputMode: ScheduleInputMode defaultInput?: string + defaultForm?: string lastRunAt?: Date nextRunAt?: Date endDate?: Date diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index 4dd33889578..16dd22a35ef 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -14,7 +14,7 @@ import { checkUsageLimit } from '../../utils/quotaUsage' import { RateLimiterManager } from '../../utils/rateLimit' import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData' import scheduleService from '../../services/schedule' -import { ScheduleBeat } from '../../queue/ScheduleBeat' +import { ScheduleBeat } from '../../schedule/ScheduleBeat' import { stripProtectedFields } from '../../utils/stripProtectedFields' const checkIfChatflowIsValidForStreaming = async (req: Request, res: Response, next: NextFunction) => { @@ -301,6 +301,32 @@ const getScheduleStatus = async (req: Request, res: Response, next: NextFunction } } +const getScheduleTriggerLogs = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params?.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: chatflowsController.getScheduleTriggerLogs - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError( + StatusCodes.NOT_FOUND, + 'Error: chatflowsController.getScheduleTriggerLogs - workspace not found!' + ) + } + const page = req.query.page ? parseInt(String(req.query.page), 10) : undefined + const limit = req.query.limit ? parseInt(String(req.query.limit), 10) : undefined + const statusRaw = req.query.status + const status = Array.isArray(statusRaw) ? (statusRaw as any) : statusRaw ? (String(statusRaw) as any) : undefined + const result = await scheduleService.getTriggerLogs(req.params.id, workspaceId, { page, limit, status }) + return res.json(result) + } catch (error) { + next(error) + } +} + const toggleScheduleEnabled = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.params?.id) { @@ -338,5 +364,6 @@ export default { getSinglePublicChatbotConfig, checkIfChatflowHasChanged, getScheduleStatus, + getScheduleTriggerLogs, toggleScheduleEnabled } diff --git a/packages/server/src/database/entities/ScheduleRecord.ts b/packages/server/src/database/entities/ScheduleRecord.ts index 8d0f066bb90..1b3310c8c0b 100644 --- a/packages/server/src/database/entities/ScheduleRecord.ts +++ b/packages/server/src/database/entities/ScheduleRecord.ts @@ -1,6 +1,6 @@ /* eslint-disable */ import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm' -import { IScheduleRecord } from '../../Interface' +import { IScheduleRecord, ScheduleInputMode } from '../../Interface' export enum ScheduleTriggerType { AGENTFLOW = 'AGENTFLOW' @@ -36,10 +36,17 @@ export class ScheduleRecord implements IScheduleRecord { @Column({ type: 'boolean', default: true }) enabled: boolean - /** Optional static text sent as question when the flow fires */ + @Column({ type: 'varchar', length: 16 }) + scheduleInputMode: ScheduleInputMode + + /** Optional static text sent as question when the flow fires (scheduleInputMode='text') */ @Column({ nullable: true, type: 'text' }) defaultInput?: string + /** Optional JSON-serialized Record passed as incomingInput.form (scheduleInputMode='form') */ + @Column({ nullable: true, type: 'text' }) + defaultForm?: string + @Column({ nullable: true }) lastRunAt?: Date diff --git a/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts index e5e398cb2ff..d665a5548e1 100644 --- a/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts @@ -11,7 +11,9 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { \`cronExpression\` text NOT NULL, \`timezone\` varchar(64) NOT NULL DEFAULT 'UTC', \`enabled\` tinyint(1) NOT NULL DEFAULT 1, + \`scheduleInputMode\` varchar(16) NOT NULL, \`defaultInput\` text, + \`defaultForm\` text, \`lastRunAt\` datetime(6), \`nextRunAt\` datetime(6), \`endDate\` datetime(6), diff --git a/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts index d40fbe8315e..d5f310c862c 100644 --- a/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts @@ -11,7 +11,9 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { \`cronExpression\` text NOT NULL, \`timezone\` varchar(64) NOT NULL DEFAULT 'UTC', \`enabled\` tinyint(1) NOT NULL DEFAULT 1, + \`scheduleInputMode\` varchar(16) NOT NULL, \`defaultInput\` text, + \`defaultForm\` text, \`lastRunAt\` datetime(6), \`nextRunAt\` datetime(6), \`endDate\` datetime(6), diff --git a/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts index c864c2ebf46..88115792c9a 100644 --- a/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts @@ -11,7 +11,9 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { "cronExpression" text NOT NULL, "timezone" varchar(64) NOT NULL DEFAULT 'UTC', "enabled" boolean NOT NULL DEFAULT true, + "scheduleInputMode" varchar(16) NOT NULL, "defaultInput" text, + "defaultForm" text, "lastRunAt" timestamp, "nextRunAt" timestamp, "endDate" timestamp, diff --git a/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts index fa42d2c62d7..773856ef67e 100644 --- a/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts +++ b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts @@ -11,7 +11,9 @@ export class AddScheduleEntities1772000000000 implements MigrationInterface { "cronExpression" text NOT NULL, "timezone" varchar(64) NOT NULL DEFAULT 'UTC', "enabled" boolean NOT NULL DEFAULT 1, + "scheduleInputMode" varchar(16) NOT NULL, "defaultInput" text, + "defaultForm" text, "lastRunAt" datetime, "nextRunAt" datetime, "endDate" datetime, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5de138e2e38..ee1d6f6dc5b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,7 +23,7 @@ import { Prometheus } from './metrics/Prometheus' import errorHandlerMiddleware from './middlewares/errors' import { NodesPool } from './NodesPool' import { QueueManager } from './queue/QueueManager' -import { ScheduleBeat } from './queue/ScheduleBeat' +import { ScheduleBeat } from './schedule/ScheduleBeat' import { RedisEventSubscriber } from './queue/RedisEventSubscriber' import flowiseApiV1Router from './routes' import { UsageCacheManager } from './UsageCacheManager' diff --git a/packages/server/src/queue/ScheduleQueue.test.ts b/packages/server/src/queue/ScheduleQueue.test.ts index 3041326f20b..6f200f74bf3 100644 --- a/packages/server/src/queue/ScheduleQueue.test.ts +++ b/packages/server/src/queue/ScheduleQueue.test.ts @@ -25,7 +25,7 @@ jest.mock('bullmq', () => ({ jest.mock('./RedisEventPublisher', () => ({ RedisEventPublisher: jest.fn().mockImplementation(() => mockRedisPublisher) })) -jest.mock('./ScheduleExecutor', () => ({ executeScheduleJob: jest.fn().mockResolvedValue(undefined) })) +jest.mock('../schedule/ScheduleExecutor', () => ({ executeScheduleJob: jest.fn().mockResolvedValue(undefined) })) jest.mock('../database/entities/ScheduleRecord', () => ({ ScheduleRecord: class ScheduleRecord {} })) @@ -43,7 +43,7 @@ jest.mock('../UsageCacheManager', () => ({ UsageCacheManager: class UsageCacheMa // ─── Imports (after mocks) ──────────────────────────────────────────────────── import { ScheduleQueue } from './ScheduleQueue' -import { executeScheduleJob } from './ScheduleExecutor' +import { executeScheduleJob } from '../schedule/ScheduleExecutor' import { RedisEventPublisher } from './RedisEventPublisher' const mockExecuteScheduleJob = executeScheduleJob as jest.Mock diff --git a/packages/server/src/queue/ScheduleQueue.ts b/packages/server/src/queue/ScheduleQueue.ts index 6514a859754..581ce540389 100644 --- a/packages/server/src/queue/ScheduleQueue.ts +++ b/packages/server/src/queue/ScheduleQueue.ts @@ -9,7 +9,7 @@ import { Telemetry } from '../utils/telemetry' import { CachePool } from '../CachePool' import { UsageCacheManager } from '../UsageCacheManager' import { RedisEventPublisher } from './RedisEventPublisher' -import { executeScheduleJob } from './ScheduleExecutor' +import { executeScheduleJob } from '../schedule/ScheduleExecutor' import { IdentityManager } from '../IdentityManager' interface ScheduleQueueOptions { diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 200b67ac8d2..3244f2093d5 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -47,5 +47,10 @@ router.get( chatflowsController.getScheduleStatus ) router.patch('/:id/schedule/enabled', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.toggleScheduleEnabled) +router.get( + '/:id/schedule/trigger-logs', + checkAnyPermission('chatflows:view,chatflows:update,agentflows:view,agentflows:update'), + chatflowsController.getScheduleTriggerLogs +) export default router diff --git a/packages/server/src/queue/ScheduleBeat.test.ts b/packages/server/src/schedule/ScheduleBeat.test.ts similarity index 99% rename from packages/server/src/queue/ScheduleBeat.test.ts rename to packages/server/src/schedule/ScheduleBeat.test.ts index 27dbb4319ba..f3e7dcf2571 100644 --- a/packages/server/src/queue/ScheduleBeat.test.ts +++ b/packages/server/src/schedule/ScheduleBeat.test.ts @@ -33,8 +33,8 @@ jest.mock('../database/entities/ScheduleRecord', () => ({ jest.mock('../utils/getRunningExpressApp', () => ({ getRunningExpressApp: jest.fn().mockReturnValue(mockAppServer) })) -jest.mock('./ScheduleQueue', () => ({ ScheduleQueue: class ScheduleQueue {} })) -jest.mock('./QueueManager', () => ({ +jest.mock('../queue/ScheduleQueue', () => ({ ScheduleQueue: class ScheduleQueue {} })) +jest.mock('../queue/QueueManager', () => ({ QueueManager: { getInstance: jest.fn().mockReturnValue({ getQueue: jest.fn().mockReturnValue(mockScheduleQueue) @@ -64,7 +64,7 @@ jest.mock('node-cron', () => ({ import { ScheduleBeat } from './ScheduleBeat' import { executeScheduleJob } from './ScheduleExecutor' import scheduleService from '../services/schedule' -import { QueueManager } from './QueueManager' +import { QueueManager } from '../queue/QueueManager' import cron from 'node-cron' const mockExecuteScheduleJob = executeScheduleJob as jest.Mock diff --git a/packages/server/src/queue/ScheduleBeat.ts b/packages/server/src/schedule/ScheduleBeat.ts similarity index 98% rename from packages/server/src/queue/ScheduleBeat.ts rename to packages/server/src/schedule/ScheduleBeat.ts index 03479fc80c5..06ee84ddfa6 100644 --- a/packages/server/src/queue/ScheduleBeat.ts +++ b/packages/server/src/schedule/ScheduleBeat.ts @@ -12,8 +12,8 @@ import { getRunningExpressApp } from '../utils/getRunningExpressApp' import { ScheduleRecord } from '../database/entities/ScheduleRecord' -import { ScheduleQueue } from './ScheduleQueue' -import { QueueManager } from './QueueManager' +import { ScheduleQueue } from '../queue/ScheduleQueue' +import { QueueManager } from '../queue/QueueManager' import { executeScheduleJob } from './ScheduleExecutor' import scheduleService from '../services/schedule' import { MODE } from '../Interface' diff --git a/packages/server/src/queue/ScheduleExecutor.test.ts b/packages/server/src/schedule/ScheduleExecutor.test.ts similarity index 81% rename from packages/server/src/queue/ScheduleExecutor.test.ts rename to packages/server/src/schedule/ScheduleExecutor.test.ts index 7828047b81b..abb620be2c3 100644 --- a/packages/server/src/queue/ScheduleExecutor.test.ts +++ b/packages/server/src/schedule/ScheduleExecutor.test.ts @@ -25,7 +25,7 @@ jest.mock('../utils/buildAgentflow', () => ({ executeAgentFlow: jest.fn() })) jest.mock('../services/schedule', () => ({ __esModule: true, default: { - isDefaultInputValid: jest.fn().mockReturnValue(true), + isScheduleInputValid: jest.fn().mockReturnValue(true), createTriggerLog: jest.fn(), updateTriggerLog: jest.fn(), updateScheduleAfterRun: jest.fn() @@ -36,7 +36,19 @@ jest.mock('../utils/logger', () => ({ default: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() } })) jest.mock('flowise-components', () => ({}), { virtual: true }) -jest.mock('../Interface', () => ({}), { virtual: true }) +jest.mock( + '../Interface', + () => ({ + ChatType: { + INTERNAL: 'INTERNAL', + EXTERNAL: 'EXTERNAL', + EVALUATION: 'EVALUATION', + MCP: 'MCP', + SCHEDULED: 'SCHEDULED' + } + }), + { virtual: true } +) jest.mock('../utils/telemetry', () => ({ Telemetry: class Telemetry {} })) jest.mock('../CachePool', () => ({ CachePool: class CachePool {} })) jest.mock('../UsageCacheManager', () => ({ UsageCacheManager: class UsageCacheManager {} })) @@ -67,6 +79,7 @@ const makeRecord = (overrides: Record = {}) => ({ timezone: 'UTC', enabled: true, workspaceId: 'ws-1', + scheduleInputMode: 'text' as const, defaultInput: 'hello', endDate: undefined as Date | undefined, nextRunAt: undefined as Date | undefined, @@ -112,7 +125,7 @@ beforeEach(() => { sseStreamer: {}, identityManager: { getProductIdFromSubscription: jest.fn().mockResolvedValue('prod-1') } } - ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(true) + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(true) ;(scheduleService.createTriggerLog as jest.Mock).mockResolvedValue({ id: 'log-1' }) ;(scheduleService.updateTriggerLog as jest.Mock).mockResolvedValue(undefined) ;(scheduleService.updateScheduleAfterRun as jest.Mock).mockResolvedValue(undefined) @@ -231,7 +244,7 @@ describe('executeScheduleJob β€” expired or invalid input', () => { it('returns undefined when default input is invalid', async () => { mockFindOneBy.mockResolvedValue(makeRecord()) - ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(false) const result = await executeScheduleJob(mockCtx, 'rec-1') @@ -241,7 +254,7 @@ describe('executeScheduleJob β€” expired or invalid input', () => { it('calls onRecordExpiredOrInvalid when default input is invalid', async () => { const record = makeRecord() mockFindOneBy.mockResolvedValue(record) - ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(false) const onRecordExpiredOrInvalid = jest.fn() await executeScheduleJob(mockCtx, 'rec-1', { onRecordExpiredOrInvalid }) @@ -251,7 +264,7 @@ describe('executeScheduleJob β€” expired or invalid input', () => { it('creates a SKIPPED log when default input is invalid', async () => { mockFindOneBy.mockResolvedValue(makeRecord()) - ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(false) await executeScheduleJob(mockCtx, 'rec-1') @@ -260,7 +273,7 @@ describe('executeScheduleJob β€” expired or invalid input', () => { it('does not execute the agentflow when input is invalid', async () => { mockFindOneBy.mockResolvedValue(makeRecord()) - ;(scheduleService.isDefaultInputValid as jest.Mock).mockReturnValue(false) + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(false) await executeScheduleJob(mockCtx, 'rec-1') @@ -388,10 +401,12 @@ describe('executeScheduleJob β€” successful execution', () => { expect(mockExecuteAgentFlow).toHaveBeenCalledWith( expect.objectContaining({ isInternal: true, - isTool: true, + chatType: 'SCHEDULED', incomingInput: expect.objectContaining({ streaming: false }) }) ) + // isTool is not set β€” scheduled runs are not tool invocations + expect(mockExecuteAgentFlow.mock.calls[0][0].isTool).toBeUndefined() }) it('uses chatflow.workspaceId when set', async () => { @@ -442,6 +457,94 @@ describe('executeScheduleJob β€” successful execution', () => { }) }) +// ─── executeScheduleJob: scheduleInputMode variants ─────────────────────────── + +describe('executeScheduleJob β€” scheduleInputMode', () => { + it('text mode (default): passes defaultInput as incomingInput.question', async () => { + const record = makeRecord({ scheduleInputMode: 'text', defaultInput: 'daily summary' }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + const call = mockExecuteAgentFlow.mock.calls[0][0] + expect(call.incomingInput.question).toBe('daily summary') + expect(call.incomingInput.form).toBeUndefined() + }) + + it('form mode: parses defaultForm JSON into incomingInput.form and omits question', async () => { + const record = makeRecord({ + scheduleInputMode: 'form', + defaultInput: '', + defaultForm: JSON.stringify({ team: 'engineering', metric: 'p95' }) + }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + const call = mockExecuteAgentFlow.mock.calls[0][0] + expect(call.incomingInput.form).toEqual({ team: 'engineering', metric: 'p95' }) + expect(call.incomingInput.question).toBeUndefined() + }) + + it('form mode: falls back to {} when defaultForm is missing', async () => { + const record = makeRecord({ scheduleInputMode: 'form', defaultInput: '', defaultForm: undefined }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + const call = mockExecuteAgentFlow.mock.calls[0][0] + expect(call.incomingInput.form).toEqual({}) + }) + + it('form mode: falls back to {} when defaultForm is invalid JSON', async () => { + const record = makeRecord({ scheduleInputMode: 'form', defaultInput: '', defaultForm: '{not valid json' }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1') + + const call = mockExecuteAgentFlow.mock.calls[0][0] + expect(call.incomingInput.form).toEqual({}) + }) + + it('none mode: passes a single-space sentinel as question (not empty string) and no form, and does not auto-disable', async () => { + // Important: form/none must not go through isScheduleInputValid at all. + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(false) + const onRecordExpiredOrInvalid = jest.fn() + const record = makeRecord({ scheduleInputMode: 'none', defaultInput: '' }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordExpiredOrInvalid }) + + const call = mockExecuteAgentFlow.mock.calls[0][0] + // Single-space sentinel β€” empty string would be filtered out by downstream Agent nodes. + expect(call.incomingInput.question).toBe(' ') + expect(call.incomingInput.form).toBeUndefined() + expect(onRecordExpiredOrInvalid).not.toHaveBeenCalled() + }) + + it('form mode: bypasses isScheduleInputValid guard (save path already validated)', async () => { + ;(scheduleService.isScheduleInputValid as jest.Mock).mockReturnValue(false) + const onRecordExpiredOrInvalid = jest.fn() + const record = makeRecord({ + scheduleInputMode: 'form', + defaultInput: '', + defaultForm: JSON.stringify({ a: 1 }) + }) + mockFindOneBy.mockResolvedValueOnce(record).mockResolvedValueOnce(makeChatFlow()) + mockExecuteAgentFlow.mockResolvedValue({}) + + await executeScheduleJob(mockCtx, 'rec-1', { onRecordExpiredOrInvalid }) + + expect(mockExecuteAgentFlow).toHaveBeenCalled() + expect(onRecordExpiredOrInvalid).not.toHaveBeenCalled() + }) +}) + // ─── executeScheduleJob: ChatFlow not found ─────────────────────────────────── describe('executeScheduleJob β€” ChatFlow not found', () => { diff --git a/packages/server/src/queue/ScheduleExecutor.ts b/packages/server/src/schedule/ScheduleExecutor.ts similarity index 89% rename from packages/server/src/queue/ScheduleExecutor.ts rename to packages/server/src/schedule/ScheduleExecutor.ts index 42bb1562cb4..5f166475b40 100644 --- a/packages/server/src/queue/ScheduleExecutor.ts +++ b/packages/server/src/schedule/ScheduleExecutor.ts @@ -7,7 +7,7 @@ */ import { DataSource } from 'typeorm' -import { IComponentNodes, IncomingAgentflowInput } from '../Interface' +import { ChatType, IComponentNodes, IncomingAgentflowInput } from '../Interface' import { IServerSideEventStreamer } from 'flowise-components' import { ScheduleRecord, ScheduleTriggerType } from '../database/entities/ScheduleRecord' import { ScheduleTriggerStatus } from '../database/entities/ScheduleTriggerLog' @@ -101,8 +101,11 @@ export async function executeScheduleJob( } // ── 2. End-date / input validation ───────────────────────────────────── - const isDefaultInputValid = scheduleService.isDefaultInputValid(scheduleRecord.defaultInput) - if ((scheduleRecord.endDate && scheduledAt >= scheduleRecord.endDate) || !isDefaultInputValid) { + const isInputValid = + scheduleRecord.scheduleInputMode === 'text' + ? scheduleService.isScheduleInputValid(scheduleRecord.scheduleInputMode, scheduleRecord.defaultInput) + : true + if ((scheduleRecord.endDate && scheduledAt >= scheduleRecord.endDate) || !isInputValid) { logger.debug(`[ScheduleExecutor]: Schedule ${scheduleRecordId} has passed end date or invalid input, disabling`) await callbacks?.onRecordExpiredOrInvalid?.(scheduleRecord) await scheduleService.createTriggerLog({ @@ -175,10 +178,19 @@ async function _executeAgentflow(ctx: ScheduleExecutionContext, record: Schedule await checkPredictions(org.id, subscriptionId, usageCacheManager) const chatId = uuidv4() - const incomingInput: IncomingAgentflowInput = { - question: record.defaultInput || '', - chatId, - streaming: false + const incomingInput: IncomingAgentflowInput = { chatId, streaming: false } + if (record.scheduleInputMode === 'form') { + try { + incomingInput.form = record.defaultForm ? JSON.parse(record.defaultForm) : {} + } catch (e) { + logger.warn(`[ScheduleExecutor]: schedule ${record.id} defaultForm is not valid JSON, falling back to {}`) + incomingInput.form = {} + } + } else if (record.scheduleInputMode === 'none') { + // Use a single-space sentinel rather than an empty string, since some models do accept whitespace characters. + incomingInput.question = ' ' + } else { + incomingInput.question = record.defaultInput } const result = await executeAgentFlow({ @@ -191,13 +203,11 @@ async function _executeAgentflow(ctx: ScheduleExecutionContext, record: Schedule cachePool, usageCacheManager, sseStreamer, - baseURL: '', + baseURL: process.env.APP_URL ?? '', isInternal: true, - uploadedFilesContent: '', - fileUploads: [], - isTool: true, - workspaceId, + chatType: ChatType.SCHEDULED, orgId, + workspaceId, subscriptionId, productId }) diff --git a/packages/server/src/services/chatflows/index.test.ts b/packages/server/src/services/chatflows/index.test.ts index 950484824ee..7d64b13f8c6 100644 --- a/packages/server/src/services/chatflows/index.test.ts +++ b/packages/server/src/services/chatflows/index.test.ts @@ -97,7 +97,7 @@ jest.mock('../../services/schedule', () => ({ deleteScheduleForTarget: jest.fn().mockResolvedValue(undefined) } })) -jest.mock('../../queue/ScheduleBeat', () => ({ +jest.mock('../../schedule/ScheduleBeat', () => ({ ScheduleBeat: { getInstance: jest.fn().mockReturnValue({ onScheduleChanged: jest.fn().mockResolvedValue(undefined) @@ -114,7 +114,7 @@ jest.mock('http-status-codes', () => ({ import chatflowsService from './index' import scheduleService from '../../services/schedule' -import { ScheduleBeat } from '../../queue/ScheduleBeat' +import { ScheduleBeat } from '../../schedule/ScheduleBeat' import { containsBase64File } from '../../utils/fileRepository' import { EnumChatflowType } from '../../database/entities/ChatFlow' import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' @@ -139,6 +139,7 @@ const makeScheduleFlowData = (inputs: Record = {}) => startInputType: 'scheduleInput', scheduleCronExpression: '* * * * *', scheduleTimezone: 'UTC', + scheduleInputMode: 'text', scheduleDefaultInput: 'hello', ...inputs } @@ -310,6 +311,67 @@ describe('saveChatflow', () => { expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith(expect.objectContaining({ endDate: undefined })) }) + // ── schedule input mode ─────────────────────────────────────────────────── + + it("defaults scheduleInputMode to 'text' and passes defaultInput when mode is not set", async () => { + mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeScheduleFlowData() })) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith( + expect.objectContaining({ scheduleInputMode: 'text', defaultInput: 'hello', defaultForm: undefined }) + ) + }) + + it("passes defaultForm (stringified) when scheduleInputMode is 'form'", async () => { + mockRepo.save.mockResolvedValue( + makeChatflow({ + flowData: makeScheduleFlowData({ + scheduleInputMode: 'form', + scheduleFormDefaults: { team: 'eng', metric: 'p95' }, + scheduleDefaultInput: '' + }) + }) + ) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + const call = mockCreateOrUpdateSchedule.mock.calls[0][0] + expect(call.scheduleInputMode).toBe('form') + expect(call.defaultInput).toBe('') // cleared in form mode + expect(JSON.parse(call.defaultForm)).toEqual({ team: 'eng', metric: 'p95' }) + }) + + it("passes empty defaultInput and no defaultForm when scheduleInputMode is 'none'", async () => { + mockRepo.save.mockResolvedValue( + makeChatflow({ flowData: makeScheduleFlowData({ scheduleInputMode: 'none', scheduleDefaultInput: 'ignored' }) }) + ) + + await chatflowsService.saveChatflow( + makeChatflow() as any, + SAVE_ARGS.orgId, + SAVE_ARGS.workspaceId, + SAVE_ARGS.subscriptionId, + SAVE_ARGS.usageCacheManager + ) + + expect(mockCreateOrUpdateSchedule).toHaveBeenCalledWith( + expect.objectContaining({ scheduleInputMode: 'none', defaultInput: '', defaultForm: undefined }) + ) + }) + it('does not create a schedule when the start node type is chatInput', async () => { mockRepo.save.mockResolvedValue(makeChatflow({ flowData: makeChatInputFlowData() })) diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 51e86afe566..9ebaa699c0f 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -2,7 +2,7 @@ import { ICommonObject, removeFolderFromStorage } from 'flowise-components' import { StatusCodes } from 'http-status-codes' import { Brackets, In } from 'typeorm' import { validate as isValidUUID } from 'uuid' -import { ChatflowType, IReactFlowObject } from '../../Interface' +import { ChatflowType, IReactFlowObject, ScheduleInputMode } from '../../Interface' import { FLOWISE_COUNTER_STATUS, FLOWISE_METRIC_COUNTERS } from '../../Interface.Metrics' import { UsageCacheManager } from '../../UsageCacheManager' import { ChatFlow, EnumChatflowType } from '../../database/entities/ChatFlow' @@ -24,7 +24,7 @@ import logger from '../../utils/logger' import { updateStorageUsage } from '../../utils/quotaUsage' import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' import scheduleService from '../../services/schedule' -import { ScheduleBeat } from '../../queue/ScheduleBeat' +import { ScheduleBeat } from '../../schedule/ScheduleBeat' export const enum ChatflowErrorMessage { INVALID_CHATFLOW_TYPE = 'Invalid Chatflow Type', @@ -333,9 +333,23 @@ const saveChatflow = async ( const startNode = nodes.find((node) => node.data.name === 'startAgentflow') const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' | 'scheduleInput' if (startInputType === 'scheduleInput') { + const scheduleInputMode = startNode?.data?.inputs?.scheduleInputMode as ScheduleInputMode | undefined + if (!scheduleInputMode) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + 'Schedule Input Mode is required on the Start node when Start Input Type is Schedule.' + ) + } const resolvedCron = scheduleService.resolveScheduleCron(startNode?.data?.inputs || {}) const scheduleTimezone = startNode?.data?.inputs?.scheduleTimezone || 'UTC' const scheduleDefaultInput = startNode?.data?.inputs?.scheduleDefaultInput || '' + const scheduleFormDefaultsRaw = startNode?.data?.inputs?.scheduleFormDefaults + const scheduleFormDefaults = + scheduleInputMode === 'form' + ? typeof scheduleFormDefaultsRaw === 'string' + ? scheduleFormDefaultsRaw + : JSON.stringify(scheduleFormDefaultsRaw ?? {}) + : undefined const scheduleEndDate = startNode?.data?.inputs?.scheduleEndDate ? new Date(startNode.data.inputs.scheduleEndDate) : undefined const enabled = scheduleService.canScheduleEnable(startNode?.data?.inputs ?? {}) const record = await scheduleService.createOrUpdateSchedule({ @@ -345,7 +359,9 @@ const saveChatflow = async ( cronExpression: resolvedCron.cronExpression || '', timezone: scheduleTimezone, enabled: enabled, - defaultInput: scheduleDefaultInput, + scheduleInputMode, + defaultInput: scheduleInputMode === 'text' ? scheduleDefaultInput : '', + defaultForm: scheduleFormDefaults, workspaceId, endDate: scheduleEndDate }) @@ -429,9 +445,23 @@ const updateChatflow = async ( const startNode = nodes.find((node) => node.data.name === 'startAgentflow') const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' | 'scheduleInput' if (startInputType === 'scheduleInput') { + const scheduleInputMode = startNode?.data?.inputs?.scheduleInputMode as ScheduleInputMode | undefined + if (!scheduleInputMode) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + 'Schedule Input Mode is required on the Start node when Start Input Type is Schedule.' + ) + } const resolvedCron = scheduleService.resolveScheduleCron(startNode?.data?.inputs || {}) const scheduleTimezone = startNode?.data?.inputs?.scheduleTimezone || 'UTC' const scheduleDefaultInput = startNode?.data?.inputs?.scheduleDefaultInput || '' + const scheduleFormDefaultsRaw = startNode?.data?.inputs?.scheduleFormDefaults + const scheduleFormDefaults = + scheduleInputMode === 'form' + ? typeof scheduleFormDefaultsRaw === 'string' + ? scheduleFormDefaultsRaw + : JSON.stringify(scheduleFormDefaultsRaw ?? {}) + : undefined const scheduleEndDate = startNode?.data?.inputs?.scheduleEndDate ? new Date(startNode.data.inputs.scheduleEndDate) : undefined const canEnable = scheduleService.canScheduleEnable(startNode?.data?.inputs ?? {}) const record = await scheduleService.createOrUpdateSchedule({ @@ -441,7 +471,9 @@ const updateChatflow = async ( cronExpression: resolvedCron.cronExpression || '', timezone: scheduleTimezone, enabled: canEnable === false ? false : undefined, // automatically disable schedule if it cannot be enabled; otherwise preserve the existing enabled value - defaultInput: scheduleDefaultInput, + scheduleInputMode, + defaultInput: scheduleInputMode === 'text' ? scheduleDefaultInput : '', + defaultForm: scheduleFormDefaults, workspaceId, endDate: scheduleEndDate }) diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 09a651848b5..5c4e4cc43e1 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -951,6 +951,8 @@ const getChatType = (chatType?: ChatType): string => { return 'API/Embed' case ChatType.MCP: return 'MCP' + case ChatType.SCHEDULED: + return 'Scheduled' } } diff --git a/packages/server/src/services/schedule/index.test.ts b/packages/server/src/services/schedule/index.test.ts index e74b3a8a83b..ef893cd14a5 100644 --- a/packages/server/src/services/schedule/index.test.ts +++ b/packages/server/src/services/schedule/index.test.ts @@ -9,6 +9,7 @@ const mockRepo = { findOne: jest.fn(), find: jest.fn(), + findAndCount: jest.fn(), create: jest.fn(), save: jest.fn(), delete: jest.fn(), @@ -76,6 +77,7 @@ const makeRecord = (overrides: Record = {}) => ({ timezone: 'UTC', enabled: true, workspaceId: 'ws-1', + scheduleInputMode: 'text' as const, defaultInput: 'hello', nodeId: undefined, endDate: undefined, @@ -117,6 +119,7 @@ describe('createOrUpdateSchedule', () => { cronExpression: '0 9 * * 1-5', timezone: 'UTC', workspaceId: 'ws-1', + scheduleInputMode: 'text' as const, defaultInput: 'Run daily job' } @@ -504,3 +507,112 @@ describe('updateTriggerLog', () => { ).resolves.toBeUndefined() }) }) + +// ─── getTriggerLogs ─────────────────────────────────────────────────────────── + +describe('getTriggerLogs', () => { + beforeEach(() => { + mockGetApp.mockReturnValue(mockAppServer) + }) + + const makeLog = (overrides: Record = {}) => ({ + id: 'log-1', + scheduleRecordId: 'rec-1', + triggerType: ScheduleTriggerType.AGENTFLOW, + targetId: 'flow-1', + status: ScheduleTriggerStatus.SUCCEEDED, + scheduledAt: new Date(), + workspaceId: 'ws-1', + elapsedTimeMs: 1234, + ...overrides + }) + + it('returns paginated logs with total count', async () => { + const logs = [makeLog(), makeLog({ id: 'log-2', status: ScheduleTriggerStatus.FAILED })] + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([logs, 42]) + + const result = await scheduleService.getTriggerLogs('flow-1', 'ws-1', { page: 2, limit: 20 }) + + expect(result.data).toHaveLength(2) + expect(result.total).toBe(42) + expect(result.page).toBe(2) + expect(result.limit).toBe(20) + }) + + it('scopes by targetId + workspaceId and orders by scheduledAt DESC', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + + await scheduleService.getTriggerLogs('flow-1', 'ws-1') + + expect(mockRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ targetId: 'flow-1', workspaceId: 'ws-1' }), + order: { scheduledAt: 'DESC' } + }) + ) + }) + + it('defaults page=1, limit=20 when not provided', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + + const result = await scheduleService.getTriggerLogs('flow-1', 'ws-1') + + expect(result.page).toBe(1) + expect(result.limit).toBe(20) + expect(mockRepo.findAndCount).toHaveBeenCalledWith(expect.objectContaining({ skip: 0, take: 20 })) + }) + + it('clamps limit to [1, 100]', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + + await scheduleService.getTriggerLogs('flow-1', 'ws-1', { limit: 500 }) + expect(mockRepo.findAndCount).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })) + + await scheduleService.getTriggerLogs('flow-1', 'ws-1', { limit: 0 }) + expect(mockRepo.findAndCount).toHaveBeenLastCalledWith(expect.objectContaining({ take: 1 })) + }) + + it('clamps page to >= 1', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + await scheduleService.getTriggerLogs('flow-1', 'ws-1', { page: 0 }) + expect(mockRepo.findAndCount).toHaveBeenCalledWith(expect.objectContaining({ skip: 0 })) + }) + + it('computes skip as (page-1) * limit', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + await scheduleService.getTriggerLogs('flow-1', 'ws-1', { page: 3, limit: 10 }) + expect(mockRepo.findAndCount).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 10 })) + }) + + it('applies a single-value status filter', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + await scheduleService.getTriggerLogs('flow-1', 'ws-1', { status: ScheduleTriggerStatus.FAILED }) + + expect(mockRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: ScheduleTriggerStatus.FAILED }) + }) + ) + }) + + it('applies an array status filter', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockResolvedValue([[], 0]) + const statuses = [ScheduleTriggerStatus.FAILED, ScheduleTriggerStatus.SKIPPED] + await scheduleService.getTriggerLogs('flow-1', 'ws-1', { status: statuses }) + + expect(mockRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ status: statuses }) }) + ) + }) + + it('wraps DB errors in InternalFlowiseError', async () => { + ;(mockRepo.findAndCount as jest.Mock).mockRejectedValue(new Error('db down')) + + await expect(scheduleService.getTriggerLogs('flow-1', 'ws-1')).rejects.toMatchObject({ + statusCode: 500, + message: expect.stringContaining('getTriggerLogs') + }) + // Use InternalFlowiseError to verify the thrown type + await expect(scheduleService.getTriggerLogs('flow-1', 'ws-1')).rejects.toBeInstanceOf(InternalFlowiseError) + }) +}) diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts index e1c8ef4b051..8ac9f4b8588 100644 --- a/packages/server/src/services/schedule/index.ts +++ b/packages/server/src/services/schedule/index.ts @@ -11,13 +11,14 @@ import { DataSource } from 'typeorm' import { validateCronExpression, computeNextRunAt, - isDefaultInputValid, + isScheduleInputValid, resolveScheduleCron, validateVisualPickerFields, buildCronFromVisualPicker, canScheduleEnable } from './utils' import { ICommonObject } from 'flowise-components' +import { ScheduleInputMode } from '../../Interface' export { validateCronExpression, @@ -25,7 +26,7 @@ export { validateVisualPickerFields, buildCronFromVisualPicker, resolveScheduleCron, - isDefaultInputValid, + isScheduleInputValid, canScheduleEnable } from './utils' export type { VisualPickerInput } from './utils' @@ -37,7 +38,9 @@ export interface CreateScheduleInput { cronExpression: string timezone?: string enabled?: boolean + scheduleInputMode: ScheduleInputMode defaultInput?: string + defaultForm?: string endDate?: Date workspaceId: string } @@ -46,7 +49,9 @@ export interface UpdateScheduleInput { cronExpression?: string timezone?: string enabled?: boolean + scheduleInputMode?: ScheduleInputMode defaultInput?: string + defaultForm?: string endDate?: Date | null } @@ -87,7 +92,9 @@ const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise => { + try { + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ScheduleTriggerLog) + + const page = Math.max(1, Math.floor(filter.page ?? 1)) + const limit = Math.max(1, Math.min(100, Math.floor(filter.limit ?? 20))) + + const where: Record = { targetId, workspaceId } + if (filter.status) { + where.status = Array.isArray(filter.status) && filter.status.length === 1 ? filter.status[0] : filter.status + } + + const [data, total] = await repo.findAndCount({ + where: where as any, + order: { scheduledAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit + }) + + return { data, total, page, limit } + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.getTriggerLogs - ${getErrorMessage(error)}` + ) + } +} + // ─── Visual Picker helpers ────────────────────────────────────────────────── export default { @@ -341,6 +407,7 @@ export default { updateTriggerLog, getScheduleStatus, toggleScheduleEnabled, - isDefaultInputValid, + getTriggerLogs, + isScheduleInputValid, canScheduleEnable } diff --git a/packages/server/src/services/schedule/utils.test.ts b/packages/server/src/services/schedule/utils.test.ts index 4577730cabb..8717ec7de97 100644 --- a/packages/server/src/services/schedule/utils.test.ts +++ b/packages/server/src/services/schedule/utils.test.ts @@ -4,7 +4,7 @@ import { validateVisualPickerFields, buildCronFromVisualPicker, resolveScheduleCron, - isDefaultInputValid, + isScheduleInputValid, canScheduleEnable, VisualPickerInput } from './utils' @@ -543,28 +543,44 @@ describe('resolveScheduleCron', () => { }) }) -// ─── isDefaultInputValid ────────────────────────────────────────────────────── +// ─── isScheduleInputValid ───────────────────────────────────────────────────── -describe('isDefaultInputValid', () => { - it('returns false for undefined', () => { - expect(isDefaultInputValid(undefined)).toBe(false) - }) - - it('returns false for empty string', () => { - expect(isDefaultInputValid('')).toBe(false) - }) - - it('returns false for rich-text empty value', () => { - expect(isDefaultInputValid('

')).toBe(false) +describe('isScheduleInputValid', () => { + describe("mode='text'", () => { + it('returns true for a non-empty default input', () => { + expect(isScheduleInputValid('text', 'hello')).toBe(true) + }) + it('returns false when default input is empty', () => { + expect(isScheduleInputValid('text', '')).toBe(false) + }) + it('returns false when default input is rich-text empty', () => { + expect(isScheduleInputValid('text', '

')).toBe(false) + }) + it('accepts whitespace-only strings (only tests truthiness + rich-text empty marker)', () => { + expect(isScheduleInputValid('text', ' ')).toBe(true) + }) }) - it('returns true for a non-empty string', () => { - expect(isDefaultInputValid('Hello from scheduler')).toBe(true) + describe("mode='form'", () => { + it('returns true when at least one form field is defined', () => { + expect(isScheduleInputValid('form', undefined, [{ name: 'team', type: 'string' }])).toBe(true) + }) + it('returns false when formInputTypes is empty', () => { + expect(isScheduleInputValid('form', undefined, [])).toBe(false) + }) + it('returns false when formInputTypes is missing', () => { + expect(isScheduleInputValid('form', undefined, undefined)).toBe(false) + }) + it('ignores defaultInput value β€” only formInputTypes matters', () => { + expect(isScheduleInputValid('form', '', [{ name: 'x', type: 'string' }])).toBe(true) + }) }) - it('returns true for a whitespace-only non-empty string', () => { - // The check only tests truthiness and the specific empty rich-text value - expect(isDefaultInputValid(' ')).toBe(true) + describe("mode='none'", () => { + it('always returns true regardless of other inputs', () => { + expect(isScheduleInputValid('none', undefined, undefined)).toBe(true) + expect(isScheduleInputValid('none', '', [])).toBe(true) + }) }) }) @@ -574,10 +590,20 @@ describe('canScheduleEnable', () => { const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString() // 30 days from now const pastDate = new Date(Date.now() - 1000 * 60 * 60).toISOString() // 1 hour ago + it('returns false when scheduleInputMode is missing', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleDefaultInput: 'hello' + }) + ).toBe(false) + }) + it('returns false when cron expression is invalid', () => { expect( canScheduleEnable({ scheduleCronExpression: 'bad-cron', + scheduleInputMode: 'text', scheduleDefaultInput: 'hello', scheduleEndDate: futureDate }) @@ -588,6 +614,7 @@ describe('canScheduleEnable', () => { expect( canScheduleEnable({ scheduleCronExpression: '* * * * *', + scheduleInputMode: 'text', scheduleDefaultInput: 'hello', scheduleEndDate: pastDate }) @@ -598,6 +625,7 @@ describe('canScheduleEnable', () => { expect( canScheduleEnable({ scheduleCronExpression: '* * * * *', + scheduleInputMode: 'text', scheduleDefaultInput: undefined }) ).toBe(false) @@ -607,6 +635,7 @@ describe('canScheduleEnable', () => { expect( canScheduleEnable({ scheduleCronExpression: '* * * * *', + scheduleInputMode: 'text', scheduleDefaultInput: '

' }) ).toBe(false) @@ -616,6 +645,7 @@ describe('canScheduleEnable', () => { expect( canScheduleEnable({ scheduleCronExpression: '0 9 * * 1-5', + scheduleInputMode: 'text', scheduleDefaultInput: 'Generate the daily report' }) ).toBe(true) @@ -625,6 +655,7 @@ describe('canScheduleEnable', () => { expect( canScheduleEnable({ scheduleCronExpression: '0 9 * * 1-5', + scheduleInputMode: 'text', scheduleDefaultInput: 'Generate the daily report', scheduleEndDate: futureDate }) @@ -637,8 +668,62 @@ describe('canScheduleEnable', () => { scheduleType: 'visualPicker', scheduleFrequency: 'daily', scheduleOnTime: '09:00', + scheduleInputMode: 'text', scheduleDefaultInput: 'Run daily job' }) ).toBe(true) }) + + describe("scheduleInputMode='form'", () => { + it('returns false when no form fields are defined', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleInputMode: 'form', + scheduleFormInputTypes: [] + }) + ).toBe(false) + }) + + it('returns true when at least one form field is defined (ignores empty defaultInput)', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleInputMode: 'form', + scheduleDefaultInput: '', + scheduleFormInputTypes: [{ name: 'team', type: 'string', label: 'Team' }] + }) + ).toBe(true) + }) + }) + + describe("scheduleInputMode='none'", () => { + it('returns true even with no default input and no form fields', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleInputMode: 'none' + }) + ).toBe(true) + }) + + it('still rejects invalid cron', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: 'not-a-cron', + scheduleInputMode: 'none' + }) + ).toBe(false) + }) + + it('still rejects past end date', () => { + expect( + canScheduleEnable({ + scheduleCronExpression: '0 9 * * 1-5', + scheduleInputMode: 'none', + scheduleEndDate: pastDate + }) + ).toBe(false) + }) + }) }) diff --git a/packages/server/src/services/schedule/utils.ts b/packages/server/src/services/schedule/utils.ts index b02564a4238..a1ad1ca1b04 100644 --- a/packages/server/src/services/schedule/utils.ts +++ b/packages/server/src/services/schedule/utils.ts @@ -3,6 +3,8 @@ * No server, database, or Express dependencies β€” safe to import and test in isolation. */ +import type { ScheduleInputMode } from '../../Interface' + // ─── Cron expression validation ────────────────────────────────────────────── const MIN_SCHEDULE_INTERVAL_SECONDS = Math.max(1, parseInt(process.env.MIN_SCHEDULE_INTERVAL_SECONDS || '60', 10) || 60) @@ -472,20 +474,27 @@ export const resolveScheduleCron = (inputs: Record): { valid: boole } /** - * Checks if the default input is valid for a scheduled flow. - * It is used to determine the initial enabled state when creating/updating a schedule, and also to validate when toggling enabled state. - * Besides, the worker skips execution of schedules that are not valid. + * Mode-aware schedule input validator. + * - 'text': requires a non-empty defaultInput (treats `

` β€” the rich-text empty marker β€” as empty). + * - 'form': requires at least one field defined in scheduleFormInputTypes. + * - 'none': always valid (flow opts out of receiving input). + * */ -export const isDefaultInputValid = (defaultInput: string | undefined): boolean => { - return !!defaultInput && defaultInput !== '

' // rich text empty value +export const isScheduleInputValid = (mode: ScheduleInputMode, defaultInput?: string, scheduleFormInputTypes?: any[]): boolean => { + if (mode === 'none') return true + if (mode === 'form') return Array.isArray(scheduleFormInputTypes) && scheduleFormInputTypes.length > 0 + return !!defaultInput && defaultInput !== '

' } /** - * Determines if a schedule can be enabled based on its inputs, including the cron expression, end date, and default input. + * Determines if a schedule can be enabled based on its inputs: cron validity, + * end date (must be in the future if set), and mode-specific input validity. */ export const canScheduleEnable = (inputs: Record): boolean => { const cronResult = resolveScheduleCron(inputs) const isEndDateValid = !inputs.scheduleEndDate || new Date(inputs.scheduleEndDate) > new Date() - const isInputValid = isDefaultInputValid(inputs.scheduleDefaultInput) + const mode = inputs.scheduleInputMode + if (!mode) return false + const isInputValid = isScheduleInputValid(mode, inputs.scheduleDefaultInput, inputs.scheduleFormInputTypes) return cronResult.valid && isEndDateValid && isInputValid } diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index 14d224f20f1..6230833f1b3 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -26,6 +26,8 @@ const getScheduleStatus = (id) => client.get(`/chatflows/${id}/schedule/status`) const toggleScheduleEnabled = (id, enabled) => client.patch(`/chatflows/${id}/schedule/enabled`, { enabled }) +const getScheduleTriggerLogs = (id, params) => client.get(`/chatflows/${id}/schedule/trigger-logs`, { params }) + export default { getAllChatflows, getAllAgentflows, @@ -39,5 +41,6 @@ export default { getHasChatflowChanged, generateAgentflow, getScheduleStatus, - toggleScheduleEnabled + toggleScheduleEnabled, + getScheduleTriggerLogs } diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx index 091640c50fc..01684be6d88 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx @@ -195,7 +195,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { const [sourceDialogProps, setSourceDialogProps] = useState({}) const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false) const [hardDeleteDialogProps, setHardDeleteDialogProps] = useState({}) - const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP']) + const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED']) const [feedbackTypeFilter, setFeedbackTypeFilter] = useState([]) const [startDate, setStartDate] = useState(new Date(new Date().setMonth(new Date().getMonth() - 1))) const [endDate, setEndDate] = useState(new Date()) @@ -349,6 +349,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { return 'Evaluation' } else if (chatType === 'MCP') { return 'MCP' + } else if (chatType === 'SCHEDULED') { + return 'Scheduled' } return 'API/Embed' } @@ -758,7 +760,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { return () => { setChatLogs([]) setChatMessages([]) - setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP']) + setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED']) setFeedbackTypeFilter([]) setSelectedMessageIndex(0) setSelectedChatId('') @@ -912,6 +914,10 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { label: 'MCP', name: 'MCP' }, + { + label: 'Scheduled', + name: 'SCHEDULED' + }, { label: 'Evaluations', name: 'EVALUATION' diff --git a/packages/ui/src/ui-component/input/suggestionOption.js b/packages/ui/src/ui-component/input/suggestionOption.js index 0247c8a059e..fe8546a2f58 100644 --- a/packages/ui/src/ui-component/input/suggestionOption.js +++ b/packages/ui/src/ui-component/input/suggestionOption.js @@ -138,11 +138,16 @@ export const suggestionOptions = ( })) const startAgentflowNode = nodes.find((node) => node.data.name === 'startAgentflow') - const formInputTypes = startAgentflowNode?.data?.inputs?.formInputTypes + const startInputType = startAgentflowNode?.data?.inputs?.startInputType + const scheduleInputMode = startAgentflowNode?.data?.inputs?.scheduleInputMode + const activeFormInputTypes = + startInputType === 'scheduleInput' && scheduleInputMode === 'form' + ? startAgentflowNode?.data?.inputs?.scheduleFormInputTypes + : startAgentflowNode?.data?.inputs?.formInputTypes let formItems = [] - if (formInputTypes) { - formItems = (formInputTypes || []).map((input) => ({ + if (activeFormInputTypes) { + formItems = (activeFormInputTypes || []).map((input) => ({ id: `$form.${input.name}`, mentionLabel: `$form.${input.name}`, description: `Form Input: ${input.label}`, diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx index 07bf57df51b..0a3cb7370f6 100644 --- a/packages/ui/src/views/agentflowsv2/Canvas.jsx +++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback, useContext } from 'react' +import { useEffect, useMemo, useRef, useState, useCallback, useContext } from 'react' import ReactFlow, { addEdge, Controls, MiniMap, Background, useNodesState, useEdgesState } from 'reactflow' import 'reactflow/dist/style.css' import './index.css' @@ -30,6 +30,7 @@ import AddNodes from '@/views/canvas/AddNodes' import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog' import ChatPopUp from '@/views/chatmessage/ChatPopUp' +import ScheduleHistoryFAB from '@/views/schedule/ScheduleHistoryFAB' import ValidationPopUp from '@/views/chatmessage/ValidationPopUp' import { flowContext } from '@/store/context/ReactFlowContext' @@ -96,6 +97,12 @@ const AgentflowCanvas = () => { const [nodes, setNodes, onNodesChange] = useNodesState() const [edges, setEdges, onEdgesChange] = useEdgesState() + const isScheduleFlow = useMemo(() => { + if (!nodes || nodes.length === 0) return false + const startNode = nodes.find((n) => n.data?.name === 'startAgentflow') + return startNode?.data?.inputs?.startInputType === 'scheduleInput' + }, [nodes]) + const [selectedNode, setSelectedNode] = useState(null) const [isSyncNodesButtonEnabled, setIsSyncNodesButtonEnabled] = useState(false) const [editNodeDialogOpen, setEditNodeDialogOpen] = useState(false) @@ -796,7 +803,11 @@ const AgentflowCanvas = () => { )} - + {isScheduleFlow ? ( + + ) : ( + + )} {!chatPopupOpen && } diff --git a/packages/ui/src/views/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx index b46ad35385c..ea455f41c2e 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.jsx +++ b/packages/ui/src/views/canvas/CanvasHeader.jsx @@ -4,11 +4,20 @@ import { useSelector, useDispatch } from 'react-redux' import { useEffect, useMemo, useRef, useState } from 'react' // material-ui -import { useTheme, styled } from '@mui/material/styles' +import { useTheme, styled, alpha } from '@mui/material/styles' import { Avatar, Box, ButtonBase, Typography, Stack, Switch, TextField, Button, Tooltip } from '@mui/material' // icons -import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconCode } from '@tabler/icons-react' +import { + IconSettings, + IconChevronLeft, + IconDeviceFloppy, + IconPencil, + IconCheck, + IconX, + IconCode, + IconAlertTriangleFilled +} from '@tabler/icons-react' // project imports import Settings from '@/views/settings' @@ -40,57 +49,72 @@ const clockCheckIcon = `url('data:image/svg+xml;utf8,')` -const ScheduleSwitch = styled(Switch)(({ theme }) => ({ - width: 62, - height: 34, - padding: 7, - '& .MuiSwitch-switchBase': { - margin: 1, - padding: 0, - transform: 'translateX(6px)', - '&.Mui-checked': { - color: '#fff', - transform: 'translateX(22px)', - '& .MuiSwitch-thumb': { - backgroundColor: theme.palette.success.dark - }, - '& .MuiSwitch-thumb:before': { - backgroundImage: clockCheckIcon - }, - '& + .MuiSwitch-track': { - opacity: 1, - backgroundColor: theme.palette.success.light +const ScheduleSwitch = styled(Switch, { shouldForwardProp: (prop) => prop !== 'isDark' })(({ theme, isDark }) => { + const offTrack = isDark ? alpha(theme.palette.success.main, 0.1) : alpha(theme.palette.success.main, 0.12) + const offThumb = isDark ? '#4a5662' : alpha(theme.palette.success.main, 0.25) + return { + width: 62, + height: 34, + padding: 7, + '& .MuiSwitch-switchBase': { + margin: 1, + padding: 0, + transform: 'translateX(6px)', + '&.Mui-checked': { + color: '#fff', + transform: 'translateX(22px)', + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.success.dark + }, + '& .MuiSwitch-thumb:before': { + backgroundImage: clockCheckIcon + }, + '& + .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.success.light + } } + }, + '& .MuiSwitch-thumb': { + backgroundColor: offThumb, + width: 32, + height: 32, + '&:before': { + content: "''", + position: 'absolute', + width: '100%', + height: '100%', + left: 0, + top: 0, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + backgroundImage: clockIcon, + opacity: 0.9 + } + }, + '& .MuiSwitch-track': { + opacity: 1, + backgroundColor: offTrack, + borderRadius: 20 / 2 + }, + '&.Mui-disabled .MuiSwitch-thumb, & .Mui-disabled .MuiSwitch-thumb': { + backgroundColor: offThumb + }, + '&.Mui-disabled + .MuiSwitch-track, & .Mui-disabled + .MuiSwitch-track': { + backgroundColor: offTrack, + opacity: 1 } - }, - '& .MuiSwitch-thumb': { - backgroundColor: theme.palette.mode === 'dark' ? '#4a5662' : '#c8cdd3', - width: 32, - height: 32, - '&:before': { - content: "''", - position: 'absolute', - width: '100%', - height: '100%', - left: 0, - top: 0, - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - backgroundImage: clockIcon, - opacity: 0.85 - } - }, - '& .MuiSwitch-track': { - opacity: 1, - backgroundColor: theme.palette.mode === 'dark' ? '#3b4450' : '#dde1e6', - borderRadius: 20 / 2 + } +}) + +const LockedScheduleSwitch = styled(ScheduleSwitch, { shouldForwardProp: (prop) => prop !== 'isDark' })(({ theme, isDark }) => ({ + '& .MuiSwitch-track, &.Mui-disabled + .MuiSwitch-track, & .Mui-disabled + .MuiSwitch-track': { + backgroundColor: isDark ? alpha(theme.palette.warning.main, 0.2) : alpha(theme.palette.warning.main, 0.15), + border: `1px solid ${alpha(theme.palette.warning.main, isDark ? 0.6 : 0.5)}`, + opacity: 1 }, '&.Mui-disabled .MuiSwitch-thumb, & .Mui-disabled .MuiSwitch-thumb': { - backgroundColor: theme.palette.mode === 'dark' ? '#2f3640' : '#e3e6ea' - }, - '&.Mui-disabled + .MuiSwitch-track, & .Mui-disabled + .MuiSwitch-track': { - backgroundColor: theme.palette.mode === 'dark' ? '#262b33' : '#eceef1', - opacity: 1 + backgroundColor: isDark ? '#4a3e1f' : '#f5e6b8' } })) @@ -131,10 +155,12 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, const getScheduleStatusApi = useApi(chatflowsApi.getScheduleStatus) const toggleScheduleEnabledApi = useApi(chatflowsApi.toggleScheduleEnabled) const canvas = useSelector((state) => state.canvas) + const isDark = useSelector((state) => state.customization.isDarkMode) const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleCanEnable, setScheduleCanEnable] = useState(false) const [scheduleCanEnableReason, setScheduleCanEnableReason] = useState('') + const [scheduleStatusLoaded, setScheduleStatusLoaded] = useState(false) const isScheduleFlow = useMemo(() => { if (!chatflow?.flowData || !isAgentflowV2) return false @@ -329,6 +355,7 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, useEffect(() => { if (chatflow?.id && isScheduleFlow) { + setScheduleStatusLoaded(false) getScheduleStatusApi.request(chatflow.id) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -339,6 +366,7 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, setScheduleEnabled(getScheduleStatusApi.data.enabled ?? false) setScheduleCanEnable(getScheduleStatusApi.data.canEnable ?? false) setScheduleCanEnableReason(getScheduleStatusApi.data.reason || '') + setScheduleStatusLoaded(true) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [getScheduleStatusApi.data]) @@ -508,7 +536,7 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow,
- {chatflow?.id && isAgentflowV2 && isScheduleFlow && ( + {chatflow?.id && isAgentflowV2 && isScheduleFlow && scheduleStatusLoaded && ( - handleToggleSchedule(e.target.checked)} - /> + {!scheduleCanEnable && !scheduleEnabled ? ( + <> + + + + ) : ( + handleToggleSchedule(e.target.checked)} + isDark={isDark} + /> + )} )} diff --git a/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx new file mode 100644 index 00000000000..08ad126593e --- /dev/null +++ b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx @@ -0,0 +1,606 @@ +import { useEffect, useMemo, useState, useCallback } from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import moment from 'moment' + +// MUI +import { + Alert, + Box, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + FormControlLabel, + IconButton, + Pagination, + Paper, + Skeleton, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography +} from '@mui/material' +import { tableCellClasses } from '@mui/material/TableCell' +import { alpha, styled, useTheme } from '@mui/material/styles' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import ErrorIcon from '@mui/icons-material/Error' +import { IconCircleMinus, IconClock, IconLoader, IconRefresh, IconX, IconCalendar } from '@tabler/icons-react' +import DragHandleIcon from '@mui/icons-material/DragHandle' + +// project import +import chatflowsApi from '@/api/chatflows' +import executionsApi from '@/api/executions' +import useApi from '@/hooks/useApi' +import { ExecutionDetails } from '@/views/agentexecutions/ExecutionDetails' + +// Drag-to-resize bounds (left-edge handle) +const MIN_DRAWER_WIDTH = 480 +const DEFAULT_DRAWER_WIDTH = 720 +const MAX_DRAWER_WIDTH = typeof window !== 'undefined' ? window.innerWidth : 1920 + +// ─── Status helpers ────────────────────────────────────────────────────────── + +const STATUS_META = { + SUCCEEDED: { label: 'OK', color: 'success.dark', Icon: CheckCircleIcon }, + FAILED: { label: 'Failed', color: 'error.main', Icon: ErrorIcon }, + SKIPPED: { label: 'Skipped', color: 'grey.500', Icon: IconCircleMinus }, + QUEUED: { label: 'Queued', color: 'info.main', Icon: IconClock }, + RUNNING: { label: 'Running', color: 'warning.dark', Icon: IconLoader } +} + +const StatusCell = ({ status }) => { + const theme = useTheme() + const meta = STATUS_META[status] ?? STATUS_META.QUEUED + const isSpin = status === 'RUNNING' + const Icon = meta.Icon + return ( + + + + + + {meta.label} + + + ) +} + +StatusCell.propTypes = { + status: PropTypes.string.isRequired +} + +// ─── Styled table cells ────────────────────────────────────────────────────── + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + borderColor: theme.palette.grey[900] + '25', + [`&.${tableCellClasses.head}`]: { + color: theme.palette.grey[900], + fontWeight: 600 + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + height: 56 + } +})) + +const StyledTableRow = styled(TableRow)(({ theme, clickable }) => ({ + cursor: clickable ? 'pointer' : 'default', + '&:hover': clickable + ? { + backgroundColor: theme.palette.action.hover + } + : {} +})) + +// ─── Time formatters ───────────────────────────────────────────────────────── + +const relTime = (date) => (date ? moment(date).fromNow() : 'β€”') +const fmtDate = (date) => (date ? moment(date).format('YYYY-MM-DD HH:mm:ss') : 'β€”') + +const fmtNextRun = (date) => { + if (!date) return { text: 'β€”', overdue: false } + const m = moment(date) + if (m.isBefore(moment())) return { text: 'due now', overdue: true } + return { text: m.fromNow(), overdue: false } +} +const fmtDuration = (ms) => { + if (ms == null) return 'β€”' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s` +} + +// ─── Cron β†’ human readable (best-effort, falls back to expression) ─────────── + +const cronHumanize = (cron, timezone) => { + if (!cron) return 'β€”' + const parts = cron.trim().split(/\s+/) + const tz = timezone && timezone !== 'UTC' ? ` (${timezone})` : ' (UTC)' + try { + // common patterns only; otherwise show the raw cron + if (parts.length === 5) { + const [m, h, dom, mon, dow] = parts + if (dom === '*' && mon === '*' && dow === '*' && m !== '*' && h !== '*') { + return `Every day at ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}${tz}` + } + if (dom === '*' && mon === '*' && dow === '1-5' && m !== '*' && h !== '*') { + return `Every weekday at ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}${tz}` + } + if (m === '0' && h === '*' && dom === '*' && mon === '*' && dow === '*') { + return `Every hour${tz}` + } + if (m === '*' && h === '*' && dom === '*' && mon === '*' && dow === '*') { + return `Every minute${tz}` + } + } + } catch { + /* noop */ + } + return `cron: ${cron}${tz}` +} + +// ─── Main drawer ───────────────────────────────────────────────────────────── + +const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + // ─── Drag-to-resize ────────────────────────────────────────────────────── + const [drawerWidth, setDrawerWidth] = useState(Math.min(DEFAULT_DRAWER_WIDTH, MAX_DRAWER_WIDTH)) + + const handleMouseMove = useCallback((e) => { + const newWidth = document.body.offsetWidth - e.clientX + if (newWidth >= MIN_DRAWER_WIDTH && newWidth <= MAX_DRAWER_WIDTH) { + setDrawerWidth(newWidth) + } + }, []) + + const handleMouseUp = useCallback(() => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + }, [handleMouseMove]) + + const handleMouseDown = useCallback(() => { + // Disable text-selection + set cursor so the cursor stays "ew-resize" while dragging + document.body.style.userSelect = 'none' + document.body.style.cursor = 'ew-resize' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [handleMouseMove, handleMouseUp]) + + // Clean up if drawer unmounts mid-drag + useEffect(() => { + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + } + }, [handleMouseMove, handleMouseUp]) + + // schedule status (cron, timezone, enabled, next-run) + const statusApi = useApi(chatflowsApi.getScheduleStatus) + const [statusData, setStatusData] = useState(null) + + // trigger logs + const logsApi = useApi(chatflowsApi.getScheduleTriggerLogs) + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const limit = 20 + + // auto-refresh + const [autoRefresh, setAutoRefresh] = useState(true) + + // execution detail drawer (nested) + const execApi = useApi(executionsApi.getExecutionById) + const [executionOpen, setExecutionOpen] = useState(false) + const [executionData, setExecutionData] = useState(null) + const [executionMetadata, setExecutionMetadata] = useState(null) + + // error modal (for rows without executionId) + const [errorModal, setErrorModal] = useState({ open: false, title: '', message: '' }) + + const fetchAll = useCallback(() => { + if (!chatflowid || !open) return + statusApi.request(chatflowid) + logsApi.request(chatflowid, { page, limit }) + }, [chatflowid, open, page, statusApi, logsApi]) + + // initial + page change + useEffect(() => { + if (open) fetchAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, page, chatflowid]) + + // poll β€” 10s default, 2s when any row is RUNNING + const hasRunning = useMemo(() => logs.some((l) => l.status === 'RUNNING'), [logs]) + useEffect(() => { + if (!open || !autoRefresh) return + const intervalMs = hasRunning ? 2000 : 10000 + const handle = setInterval(() => fetchAll(), intervalMs) + return () => clearInterval(handle) + }, [open, autoRefresh, hasRunning, fetchAll]) + + useEffect(() => { + if (statusApi.data) setStatusData(statusApi.data) + }, [statusApi.data]) + + useEffect(() => { + if (logsApi.data) { + setLogs(logsApi.data.data ?? []) + setTotal(logsApi.data.total ?? 0) + } + }, [logsApi.data]) + + // ─── Row click β†’ execution details or error modal ──────────────────────── + + const handleRowClick = async (row) => { + if (row.executionId) { + try { + const resp = await executionsApi.getExecutionById(row.executionId) + const execution = resp.data + // executionData is stored as a JSON string in the DB; the ExecutionDetails + // component expects the pre-parsed array (same shape as agentexecutions/index.jsx). + const parsedExecutionData = + typeof execution?.executionData === 'string' ? JSON.parse(execution.executionData) : execution?.executionData + setExecutionData(parsedExecutionData) + setExecutionMetadata({ + id: execution?.id, + sessionId: execution?.sessionId, + createdDate: execution?.createdDate, + updatedDate: execution?.updatedDate, + state: execution?.state, + agentflow: execution?.agentflow + }) + setExecutionOpen(true) + } catch (e) { + setErrorModal({ + open: true, + title: 'Could not load execution', + message: e?.response?.data?.message || e?.message || 'Unknown error' + }) + } + } else if (row.status === 'FAILED' || row.status === 'SKIPPED') { + setErrorModal({ + open: true, + title: row.status === 'FAILED' ? 'Run failed before execution started' : 'Run was skipped', + message: + row.error || + (row.status === 'SKIPPED' + ? 'The schedule was skipped (commonly: disabled, past end date, or invalid input).' + : 'No further details available.') + }) + } + } + + // ─── Header derived values ─────────────────────────────────────────────── + + const record = statusData?.record + const enabled = !!statusData?.enabled + const cronHuman = cronHumanize(record?.cronExpression, record?.timezone) + const nextRunAt = record?.nextRunAt + const lastLog = logs[0] + + const totalPages = Math.max(1, Math.ceil(total / limit)) + + return ( + <> + + {/* Left-edge drag handle: click-and-drag to resize */} + + {/* Header */} + + + + + + Schedule History + + + + + + + + + + + {cronHuman} + + + + + + + Last run + + + {lastLog ? relTime(lastLog.scheduledAt) : 'β€”'} + + + + + Next run + + {(() => { + if (!enabled || !nextRunAt) { + return β€” + } + const { text, overdue } = fmtNextRun(nextRunAt) + return ( + + + {text} + + + ) + })()} + + + + + + + + + + setAutoRefresh(e.target.checked)} />} + label={Auto-refresh} + /> + {logsApi.loading && } + + + {!enabled && statusData?.reason && ( + alpha(t.palette.info.main, 0.15), + color: 'info.light', + border: (t) => `1px solid ${alpha(t.palette.info.main, 0.3)}`, + '& .MuiAlert-message': { color: 'info.light' } + }) + }} + > + {statusData.reason} + + )} + + + {/* Table */} + + {logsApi.loading && logs.length === 0 ? ( + + {[...Array(5)].map((_, i) => ( + + ))} + + ) : logs.length === 0 ? ( + + + + No runs yet. + {enabled && nextRunAt ? ` Next fire ${relTime(nextRunAt)}.` : ''} + + + ) : ( + + + + + Status + Scheduled At + Duration + Error + + + + {logs.map((row) => { + const clickable = !!row.executionId || row.status === 'FAILED' || row.status === 'SKIPPED' + return ( + handleRowClick(row) : undefined} + > + + + + + + {relTime(row.scheduledAt)} + + + {fmtDuration(row.elapsedTimeMs)} + + {row.error ? ( + + {row.error} + + ) : ( + β€” + )} + + + ) + })} + +
+
+ )} +
+ + {/* Footer pagination */} + {totalPages > 1 && ( + + setPage(v)} size='small' color='primary' /> + + )} +
+ + {/* Nested execution drawer β€” executionData is already the parsed array */} + {executionOpen && executionData && ( + setExecutionOpen(false)} + onRefresh={() => execApi.request(executionMetadata?.id)} + isPublic={false} + /> + )} + + {/* Error modal for rows without an executionId */} + setErrorModal({ open: false, title: '', message: '' })} maxWidth='sm' fullWidth> + {errorModal.title} + + + {errorModal.message} + + + + setErrorModal({ open: false, title: '', message: '' })} size='small'> + + + + + + ) +} + +ScheduleHistoryDrawer.propTypes = { + open: PropTypes.bool.isRequired, + chatflowid: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired +} + +export default ScheduleHistoryDrawer diff --git a/packages/ui/src/views/schedule/ScheduleHistoryFAB.jsx b/packages/ui/src/views/schedule/ScheduleHistoryFAB.jsx new file mode 100644 index 00000000000..502265a57f4 --- /dev/null +++ b/packages/ui/src/views/schedule/ScheduleHistoryFAB.jsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { Badge, Tooltip } from '@mui/material' +import { IconHistory } from '@tabler/icons-react' + +// project import +import { StyledFab } from '@/ui-component/button/StyledFab' +import chatflowsApi from '@/api/chatflows' +import useApi from '@/hooks/useApi' +import ScheduleHistoryDrawer from './ScheduleHistoryDrawer' + +const ScheduleHistoryFAB = ({ chatflowid, onOpenChange }) => { + const [open, setOpen] = useState(false) + const [runningCount, setRunningCount] = useState(0) + + const probeApi = useApi(chatflowsApi.getScheduleTriggerLogs) + + // Cheap background poll to show the "running" badge even when drawer is closed. + // Only while FAB is mounted (i.e., a schedule flow is loaded on the canvas). + useEffect(() => { + if (!chatflowid) return + let handle + const tick = () => probeApi.request(chatflowid, { page: 1, limit: 5, status: 'RUNNING' }) + tick() + handle = setInterval(tick, 15000) + return () => clearInterval(handle) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatflowid]) + + useEffect(() => { + if (probeApi.data) setRunningCount(probeApi.data.total ?? 0) + }, [probeApi.data]) + + const handleToggle = () => { + const next = !open + setOpen(next) + if (onOpenChange) onOpenChange(next) + } + + return ( + <> + + 0 ? 'pulse 1.2s ease-in-out infinite' : 'none' + }, + '@keyframes pulse': { + '0%, 100%': { opacity: 1, transform: 'scale(1)' }, + '50%': { opacity: 0.4, transform: 'scale(1.4)' } + } + }} + > + + + + + + + setOpen(false)} /> + + ) +} + +ScheduleHistoryFAB.propTypes = { + chatflowid: PropTypes.string.isRequired, + onOpenChange: PropTypes.func +} + +export default ScheduleHistoryFAB From 1661d3b7a946656a986f86283f6be8bd197adeb3 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 25 Apr 2026 17:43:00 +0100 Subject: [PATCH 13/15] add deleteScheduleTriggerLogs endpoint and UI integration for log deletion --- .../server/src/controllers/chatflows/index.ts | 27 +++ packages/server/src/routes/chatflows/index.ts | 5 + .../src/services/schedule/index.test.ts | 90 ++++++++ .../server/src/services/schedule/index.ts | 52 ++++- packages/ui/src/api/chatflows.js | 5 +- .../views/schedule/ScheduleHistoryDrawer.jsx | 192 ++++++++++++++++-- 6 files changed, 347 insertions(+), 24 deletions(-) diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index 16dd22a35ef..f16d7f0de86 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -327,6 +327,32 @@ const getScheduleTriggerLogs = async (req: Request, res: Response, next: NextFun } } +const deleteScheduleTriggerLogs = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params?.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: chatflowsController.deleteScheduleTriggerLogs - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError( + StatusCodes.NOT_FOUND, + 'Error: chatflowsController.deleteScheduleTriggerLogs - workspace not found!' + ) + } + const logIds: unknown = req.body?.logIds + if (!Array.isArray(logIds) || logIds.some((x) => typeof x !== 'string')) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'logIds must be a string[]') + } + const result = await scheduleService.deleteTriggerLogs(req.params.id, workspaceId, logIds as string[]) + return res.json(result) + } catch (error) { + next(error) + } +} + const toggleScheduleEnabled = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.params?.id) { @@ -365,5 +391,6 @@ export default { checkIfChatflowHasChanged, getScheduleStatus, getScheduleTriggerLogs, + deleteScheduleTriggerLogs, toggleScheduleEnabled } diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 3244f2093d5..22eaac4bfba 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -52,5 +52,10 @@ router.get( checkAnyPermission('chatflows:view,chatflows:update,agentflows:view,agentflows:update'), chatflowsController.getScheduleTriggerLogs ) +router.delete( + '/:id/schedule/trigger-logs', + checkAnyPermission('chatflows:update,agentflows:update,executions:delete'), + chatflowsController.deleteScheduleTriggerLogs +) export default router diff --git a/packages/server/src/services/schedule/index.test.ts b/packages/server/src/services/schedule/index.test.ts index ef893cd14a5..552f381e86e 100644 --- a/packages/server/src/services/schedule/index.test.ts +++ b/packages/server/src/services/schedule/index.test.ts @@ -50,6 +50,12 @@ jest.mock('../../errors/internalFlowiseError', () => ({ })) jest.mock('../../errors/utils', () => ({ getErrorMessage: (e: unknown) => String(e) })) jest.mock('../../utils/getRunningExpressApp', () => ({ getRunningExpressApp: jest.fn().mockReturnValue(mockAppServer) })) +jest.mock('../executions', () => ({ + __esModule: true, + default: { + deleteExecutions: jest.fn().mockResolvedValue({ success: true, deletedCount: 0 }) + } +})) jest.mock('../../utils/logger', () => ({ __esModule: true, default: { debug: jest.fn(), error: jest.fn(), info: jest.fn() } @@ -616,3 +622,87 @@ describe('getTriggerLogs', () => { await expect(scheduleService.getTriggerLogs('flow-1', 'ws-1')).rejects.toBeInstanceOf(InternalFlowiseError) }) }) + +// ─── deleteTriggerLogs ──────────────────────────────────────────────────────── + +import executionsService from '../executions' + +describe('deleteTriggerLogs', () => { + const mockDeleteExecutions = (executionsService as any).deleteExecutions as jest.Mock + + beforeEach(() => { + mockGetApp.mockReturnValue(mockAppServer) + mockRepo.find = jest.fn() + mockRepo.delete = jest.fn() + mockDeleteExecutions.mockReset().mockResolvedValue({ success: true, deletedCount: 0 }) + }) + + const makeLog = (id: string, executionId?: string) => ({ + id, + targetId: 'flow-1', + workspaceId: 'ws-1', + scheduleRecordId: 'rec-1', + triggerType: ScheduleTriggerType.AGENTFLOW, + status: ScheduleTriggerStatus.SUCCEEDED, + executionId, + scheduledAt: new Date() + }) + + it('returns zero counts when logIds is empty', async () => { + const result = await scheduleService.deleteTriggerLogs('flow-1', 'ws-1', []) + expect(result).toEqual({ success: true, deletedLogs: 0, deletedExecutions: 0 }) + expect(mockRepo.find).not.toHaveBeenCalled() + expect(mockRepo.delete).not.toHaveBeenCalled() + }) + + it('returns zero counts when no logs match (cross-workspace deletion attempt)', async () => { + ;(mockRepo.find as jest.Mock).mockResolvedValue([]) + + const result = await scheduleService.deleteTriggerLogs('flow-1', 'ws-1', ['log-from-other-ws']) + + expect(result).toEqual({ success: true, deletedLogs: 0, deletedExecutions: 0 }) + expect(mockRepo.delete).not.toHaveBeenCalled() + expect(mockDeleteExecutions).not.toHaveBeenCalled() + }) + + it('scopes the find query by id + targetId + workspaceId', async () => { + ;(mockRepo.find as jest.Mock).mockResolvedValue([]) + await scheduleService.deleteTriggerLogs('flow-1', 'ws-1', ['log-1', 'log-2']) + + expect(mockRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ targetId: 'flow-1', workspaceId: 'ws-1' }) + }) + ) + }) + + it('deletes logs and cascades to executions for logs that have executionId', async () => { + const logs = [makeLog('log-1', 'exec-1'), makeLog('log-2'), makeLog('log-3', 'exec-3')] + ;(mockRepo.find as jest.Mock).mockResolvedValue(logs) + ;(mockRepo.delete as jest.Mock).mockResolvedValue({ affected: 3 }) + mockDeleteExecutions.mockResolvedValue({ success: true, deletedCount: 2 }) + + const result = await scheduleService.deleteTriggerLogs('flow-1', 'ws-1', ['log-1', 'log-2', 'log-3']) + + expect(result.deletedLogs).toBe(3) + expect(result.deletedExecutions).toBe(2) + expect(mockDeleteExecutions).toHaveBeenCalledWith(['exec-1', 'exec-3'], 'ws-1') + }) + + it('skips execution cascade when no logs have an executionId', async () => { + ;(mockRepo.find as jest.Mock).mockResolvedValue([makeLog('log-1'), makeLog('log-2')]) + ;(mockRepo.delete as jest.Mock).mockResolvedValue({ affected: 2 }) + + const result = await scheduleService.deleteTriggerLogs('flow-1', 'ws-1', ['log-1', 'log-2']) + + expect(result.deletedLogs).toBe(2) + expect(result.deletedExecutions).toBe(0) + expect(mockDeleteExecutions).not.toHaveBeenCalled() + }) + + it('wraps DB errors in InternalFlowiseError', async () => { + ;(mockRepo.find as jest.Mock).mockRejectedValue(new Error('db down')) + + await expect(scheduleService.deleteTriggerLogs('flow-1', 'ws-1', ['log-1'])).rejects.toBeInstanceOf(InternalFlowiseError) + }) +}) diff --git a/packages/server/src/services/schedule/index.ts b/packages/server/src/services/schedule/index.ts index 8ac9f4b8588..dee0a74cf4e 100644 --- a/packages/server/src/services/schedule/index.ts +++ b/packages/server/src/services/schedule/index.ts @@ -1,5 +1,6 @@ import { StatusCodes } from 'http-status-codes' import { v4 as uuidv4 } from 'uuid' +import { DataSource, In } from 'typeorm' import { ScheduleRecord, ScheduleTriggerType } from '../../database/entities/ScheduleRecord' import { ScheduleTriggerLog, ScheduleTriggerStatus } from '../../database/entities/ScheduleTriggerLog' import { ChatFlow } from '../../database/entities/ChatFlow' @@ -7,7 +8,7 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import logger from '../../utils/logger' -import { DataSource } from 'typeorm' +import executionsService from '../executions' import { validateCronExpression, computeNextRunAt, @@ -391,6 +392,54 @@ const getTriggerLogs = async ( } } +/** + * Deletes trigger-log rows by id, scoped to a workspace + target so a user from one workspace + * can't delete another's logs. Cascades to the linked Execution rows (and clears + * ChatMessage.executionId pointers via executionsService.deleteExecutions). + * + * @returns counts of deleted logs and executions + */ +const deleteTriggerLogs = async ( + targetId: string, + workspaceId: string, + logIds: string[] +): Promise<{ success: boolean; deletedLogs: number; deletedExecutions: number }> => { + try { + if (!Array.isArray(logIds) || logIds.length === 0) { + return { success: true, deletedLogs: 0, deletedExecutions: 0 } + } + + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ScheduleTriggerLog) + + // Load first so we can extract executionIds before delete (and respect target/workspace scope). + const logs = await repo.find({ where: { id: In(logIds), targetId, workspaceId } }) + if (logs.length === 0) { + return { success: true, deletedLogs: 0, deletedExecutions: 0 } + } + + const executionIds = logs.map((l) => l.executionId).filter((id): id is string => !!id) + const idsToDelete = logs.map((l) => l.id) + + const result = await repo.delete({ id: In(idsToDelete) }) + + let deletedExecutions = 0 + if (executionIds.length > 0) { + const execResult = await executionsService.deleteExecutions(executionIds, workspaceId) + deletedExecutions = execResult.deletedCount ?? 0 + } + + logger.debug(`[ScheduleService]: Deleted ${result.affected ?? 0} trigger logs and ${deletedExecutions} executions`) + return { success: true, deletedLogs: result.affected ?? 0, deletedExecutions } + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: scheduleService.deleteTriggerLogs - ${getErrorMessage(error)}` + ) + } +} + // ─── Visual Picker helpers ────────────────────────────────────────────────── export default { @@ -408,6 +457,7 @@ export default { getScheduleStatus, toggleScheduleEnabled, getTriggerLogs, + deleteTriggerLogs, isScheduleInputValid, canScheduleEnable } diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index 6230833f1b3..ea42c67225c 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -28,6 +28,8 @@ const toggleScheduleEnabled = (id, enabled) => client.patch(`/chatflows/${id}/sc const getScheduleTriggerLogs = (id, params) => client.get(`/chatflows/${id}/schedule/trigger-logs`, { params }) +const deleteScheduleTriggerLogs = (id, logIds) => client.delete(`/chatflows/${id}/schedule/trigger-logs`, { data: { logIds } }) + export default { getAllChatflows, getAllAgentflows, @@ -42,5 +44,6 @@ export default { generateAgentflow, getScheduleStatus, toggleScheduleEnabled, - getScheduleTriggerLogs + getScheduleTriggerLogs, + deleteScheduleTriggerLogs } diff --git a/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx index 08ad126593e..b3807159660 100644 --- a/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx +++ b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx @@ -7,16 +7,18 @@ import moment from 'moment' import { Alert, Box, + Button, + Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent, + DialogContentText, DialogTitle, Drawer, FormControlLabel, IconButton, - Pagination, Paper, Skeleton, Stack, @@ -34,15 +36,21 @@ import { tableCellClasses } from '@mui/material/TableCell' import { alpha, styled, useTheme } from '@mui/material/styles' import CheckCircleIcon from '@mui/icons-material/CheckCircle' import ErrorIcon from '@mui/icons-material/Error' -import { IconCircleMinus, IconClock, IconLoader, IconRefresh, IconX, IconCalendar } from '@tabler/icons-react' +import { IconCircleMinus, IconClock, IconLoader, IconRefresh, IconX, IconCalendar, IconTrash } from '@tabler/icons-react' import DragHandleIcon from '@mui/icons-material/DragHandle' // project import import chatflowsApi from '@/api/chatflows' import executionsApi from '@/api/executions' import useApi from '@/hooks/useApi' +import useNotifier from '@/utils/useNotifier' +import { useDispatch } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions' +import TablePagination, { DEFAULT_ITEMS_PER_PAGE } from '@/ui-component/pagination/TablePagination' import { ExecutionDetails } from '@/views/agentexecutions/ExecutionDetails' +const PAGE_SIZE_STORAGE_KEY = 'scheduleHistoryPageSize' + // Drag-to-resize bounds (left-edge handle) const MIN_DRAWER_WIDTH = 480 const DEFAULT_DRAWER_WIDTH = 720 @@ -202,11 +210,19 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { const [logs, setLogs] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) - const limit = 20 + const [limit, setLimit] = useState(() => { + const stored = parseInt(localStorage.getItem(PAGE_SIZE_STORAGE_KEY) || '', 10) + return Number.isFinite(stored) && stored > 0 ? stored : DEFAULT_ITEMS_PER_PAGE + }) // auto-refresh const [autoRefresh, setAutoRefresh] = useState(true) + // selection + delete + const [selectedIds, setSelectedIds] = useState([]) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + // execution detail drawer (nested) const execApi = useApi(executionsApi.getExecutionById) const [executionOpen, setExecutionOpen] = useState(false) @@ -216,17 +232,37 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { // error modal (for rows without executionId) const [errorModal, setErrorModal] = useState({ open: false, title: '', message: '' }) + // snackbar plumbing + useNotifier() + const dispatch = useDispatch() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + const fetchAll = useCallback(() => { if (!chatflowid || !open) return statusApi.request(chatflowid) logsApi.request(chatflowid, { page, limit }) - }, [chatflowid, open, page, statusApi, logsApi]) + }, [chatflowid, open, page, limit, statusApi, logsApi]) - // initial + page change + // initial + page/limit change useEffect(() => { if (open) fetchAll() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, page, chatflowid]) + }, [open, page, limit, chatflowid]) + + const handlePaginationChange = (nextPage, nextLimit) => { + if (nextLimit !== limit) { + localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(nextLimit)) + setLimit(nextLimit) + setPage(1) + // selections refer to the previous page's row ids β€” drop them on page-size change + setSelectedIds([]) + } else { + setPage(nextPage) + // optional: persist selection across pagination β€” for now drop to avoid stale state + setSelectedIds([]) + } + } // poll β€” 10s default, 2s when any row is RUNNING const hasRunning = useMemo(() => logs.some((l) => l.status === 'RUNNING'), [logs]) @@ -289,6 +325,68 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { } } + // ─── Selection helpers ─────────────────────────────────────────────────── + + const visibleIds = useMemo(() => logs.map((l) => l.id), [logs]) + const allOnPageSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.includes(id)) + const someOnPageSelected = visibleIds.some((id) => selectedIds.includes(id)) + + const toggleRowSelected = (id) => { + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + } + + const toggleSelectAllOnPage = () => { + if (allOnPageSelected) { + // deselect every row from the current page + setSelectedIds((prev) => prev.filter((id) => !visibleIds.includes(id))) + } else { + // add any not-already-selected rows from the current page + setSelectedIds((prev) => Array.from(new Set([...prev, ...visibleIds]))) + } + } + + const handleConfirmDelete = async () => { + if (selectedIds.length === 0) return + setDeleting(true) + try { + const resp = await chatflowsApi.deleteScheduleTriggerLogs(chatflowid, selectedIds) + const data = resp?.data ?? {} + enqueueSnackbar({ + message: `Deleted ${data.deletedLogs ?? selectedIds.length} log${ + (data.deletedLogs ?? selectedIds.length) === 1 ? '' : 's' + }${data.deletedExecutions ? ` and ${data.deletedExecutions} execution${data.deletedExecutions === 1 ? '' : 's'}` : ''}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + setSelectedIds([]) + setDeleteDialogOpen(false) + fetchAll() + } catch (e) { + enqueueSnackbar({ + message: e?.response?.data?.message || e?.message || 'Failed to delete logs', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } finally { + setDeleting(false) + } + } + // ─── Header derived values ─────────────────────────────────────────────── const record = statusData?.record @@ -297,8 +395,6 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { const nextRunAt = record?.nextRunAt const lastLog = logs[0] - const totalPages = Math.max(1, Math.ceil(total / limit)) - return ( <> { control={ setAutoRefresh(e.target.checked)} />} label={Auto-refresh} /> + + + {/* span wrapper so Tooltip works on a disabled button */} + + setDeleteDialogOpen(true)} + disabled={selectedIds.length === 0 || deleting} + > + + + + {logsApi.loading && } @@ -492,6 +602,15 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { + + + Status Scheduled At Duration @@ -501,22 +620,30 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { {logs.map((row) => { const clickable = !!row.executionId || row.status === 'FAILED' || row.status === 'SKIPPED' + const isSelected = selectedIds.includes(row.id) return ( - handleRowClick(row) : undefined} - > - + + e.stopPropagation()}> + toggleRowSelected(row.id)} + inputProps={{ 'aria-label': `Select row ${row.id}` }} + /> + + handleRowClick(row) : undefined}> - + handleRowClick(row) : undefined}> {relTime(row.scheduledAt)} - {fmtDuration(row.elapsedTimeMs)} + handleRowClick(row) : undefined}> + {fmtDuration(row.elapsedTimeMs)} + handleRowClick(row) : undefined} sx={{ maxWidth: 240, overflow: 'hidden', @@ -542,17 +669,16 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { )} - {/* Footer pagination */} - {totalPages > 1 && ( + {/* Footer: items-per-page + page selector + total count (mirrors Agent Executions pattern) */} + {total > 0 && ( - setPage(v)} size='small' color='primary' /> + )} @@ -569,6 +695,28 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { /> )} + {/* Bulk-delete confirmation */} + !deleting && setDeleteDialogOpen(false)} maxWidth='sm' fullWidth> + + Delete {selectedIds.length} log{selectedIds.length === 1 ? '' : 's'}? + + + + This will also permanently delete the linked execution traces. Schedule trigger logs that never produced an + execution (skipped or pre-execution failures) are deleted but have no associated execution to remove. This action + cannot be undone. + + + + + + + + {/* Error modal for rows without an executionId */} setErrorModal({ open: false, title: '', message: '' })} maxWidth='sm' fullWidth> {errorModal.title} From a5025f7349251d568a52fefd52e485d7d67a9a69 Mon Sep 17 00:00:00 2001 From: Hoang Doan Date: Wed, 29 Apr 2026 20:13:01 +0700 Subject: [PATCH 14/15] feat: enhance cron job handling for last day of month (L) in ScheduleBeat and related utilities Co-authored-by: Copilot --- .../server/src/schedule/ScheduleBeat.test.ts | 82 ++++++ packages/server/src/schedule/ScheduleBeat.ts | 25 +- .../src/services/schedule/utils.test.ts | 245 ++++++++++++++++++ .../server/src/services/schedule/utils.ts | 130 +++++++++- .../ui-component/picker/MonthDaysPicker.jsx | 76 +++--- 5 files changed, 517 insertions(+), 41 deletions(-) diff --git a/packages/server/src/schedule/ScheduleBeat.test.ts b/packages/server/src/schedule/ScheduleBeat.test.ts index f3e7dcf2571..e1d5d37d860 100644 --- a/packages/server/src/schedule/ScheduleBeat.test.ts +++ b/packages/server/src/schedule/ScheduleBeat.test.ts @@ -427,6 +427,88 @@ describe('_upsertCronJob', () => { expect(onCronFire).toHaveBeenCalledWith('rec-1') onCronFire.mockRestore() }) + + // ── `L` (last day of month) compatibility with node-cron ─────────── + + it('expands `L` in DOM field to `28-31` before handing the cron expression to node-cron', () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: '0 9 L * *' })) + + // node-cron is the one that does not understand L; it must receive the expanded expression. + expect(mockCronValidate).toHaveBeenCalledWith('0 9 28-31 * *') + expect(mockCronSchedule).toHaveBeenCalledWith('0 9 28-31 * *', expect.any(Function), { timezone: 'UTC' }) + }) + + it('expands `L` correctly inside a comma-separated DOM list', () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: '0 9 1,15,L * *' })) + + expect(mockCronSchedule).toHaveBeenCalledWith('0 9 1,15,28-31 * *', expect.any(Function), { timezone: 'UTC' }) + }) + + it('expands `L` correctly when the last day of the month is specified and L special character', () => { + const beat = ScheduleBeat.getInstance() + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: '0 9 31,L * *' })) + + // L should be expanded to 28-31 even if 31 is already present, to ensure the runtime filter logic works correctly. + expect(mockCronSchedule).toHaveBeenCalledWith('0 9 28-31 * *', expect.any(Function), { timezone: 'UTC' }) + }) + + it('skips firing on candidate days that are not actually the last day of the month', () => { + const beat = ScheduleBeat.getInstance() + const onCronFire = jest.spyOn(beat as any, '_onCronFire').mockResolvedValue(undefined) + + // Register an L-based cron. node-cron will fire on 28/29/30/31 every month; + // ScheduleBeat must filter out the spurious days. + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: '0 9 L * *' })) + const cronCallback = mockCronSchedule.mock.calls[0][1] as () => void + + // Pretend node-cron fired on Jan 30 2025 β€” Jan has 31 days, so this is NOT the last day. + jest.useFakeTimers().setSystemTime(new Date('2025-01-30T09:00:00Z')) + cronCallback() + expect(onCronFire).not.toHaveBeenCalled() + + // Now Jan 31 2025 β€” the actual last day. + jest.setSystemTime(new Date('2025-01-31T09:00:00Z')) + cronCallback() + expect(onCronFire).toHaveBeenCalledWith('rec-1') + + jest.useRealTimers() + onCronFire.mockRestore() + }) + + it('does not apply runtime DOM filtering when the original expression has no L', () => { + const beat = ScheduleBeat.getInstance() + const onCronFire = jest.spyOn(beat as any, '_onCronFire').mockResolvedValue(undefined) + + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: '0 9 * * 1-5' })) + const cronCallback = mockCronSchedule.mock.calls[0][1] as () => void + + // Any date should fire because there is no DOM filter to reject it. + jest.useFakeTimers().setSystemTime(new Date('2025-01-30T09:00:00Z')) + cronCallback() + expect(onCronFire).toHaveBeenCalledWith('rec-1') + + jest.useRealTimers() + onCronFire.mockRestore() + }) + + it('passes the schedule timezone through to the runtime DOM filter', () => { + const beat = ScheduleBeat.getInstance() + const onCronFire = jest.spyOn(beat as any, '_onCronFire').mockResolvedValue(undefined) + + ;(beat as any)._upsertCronJob(makeRecord({ cronExpression: '0 9 L * *', timezone: 'America/New_York' })) + expect(mockCronSchedule).toHaveBeenCalledWith('0 9 28-31 * *', expect.any(Function), { timezone: 'America/New_York' }) + const cronCallback = mockCronSchedule.mock.calls[0][1] as () => void + + // 2025-02-01T03:00:00Z is Jan 31 22:00 in America/New_York β†’ last day in the schedule's tz + jest.useFakeTimers().setSystemTime(new Date('2025-02-01T03:00:00Z')) + cronCallback() + expect(onCronFire).toHaveBeenCalledWith('rec-1') + + jest.useRealTimers() + onCronFire.mockRestore() + }) }) // ─── _removeCronJob ─────────────────────────────────────────────────────────── diff --git a/packages/server/src/schedule/ScheduleBeat.ts b/packages/server/src/schedule/ScheduleBeat.ts index 06ee84ddfa6..33b1450f2b9 100644 --- a/packages/server/src/schedule/ScheduleBeat.ts +++ b/packages/server/src/schedule/ScheduleBeat.ts @@ -16,6 +16,7 @@ import { ScheduleQueue } from '../queue/ScheduleQueue' import { QueueManager } from '../queue/QueueManager' import { executeScheduleJob } from './ScheduleExecutor' import scheduleService from '../services/schedule' +import { expandCronLForNodeCron, cronDomMatchesNow } from '../services/schedule/utils' import { MODE } from '../Interface' import logger from '../utils/logger' import cron, { ScheduledTask } from 'node-cron' @@ -161,20 +162,35 @@ export class ScheduleBeat { /** * Register (or re-register) a node-cron job for a schedule record. + * + * `node-cron` does not support the `L` (last day of month) token, while BullMQ / + * cron-parser does. To keep both backends in sync we expand `L` β†’ `28-31` for + * node-cron's parser and add a runtime DOM filter so candidate days only + * actually fire when they really are the last day of the current month. */ private _upsertCronJob(record: ScheduleRecord): void { this._removeCronJob(record.id) const tz = record.timezone ?? 'UTC' - if (!cron.validate(record.cronExpression)) { + const { expression: nodeCronExpression, hasL } = expandCronLForNodeCron(record.cronExpression) + + if (!cron.validate(nodeCronExpression)) { logger.warn(`[ScheduleBeat]: Invalid cron expression for schedule ${record.id}: "${record.cronExpression}", skipping`) return } const task = cron.schedule( - record.cronExpression, + nodeCronExpression, () => { + // When the original expression used `L`, only fire on a real match + // (i.e. today's DOM in `tz` actually satisfies the original DOM field). + if (hasL && !cronDomMatchesNow(record.cronExpression, new Date(), tz)) { + logger.debug( + `[ScheduleBeat]: Skipping cron fire for schedule ${record.id} because today does not match original DOM field with L token` + ) + return + } this._onCronFire(record.id).catch((err) => { logger.error(`[ScheduleBeat]: Error firing schedule ${record.id}: ${err}`) }) @@ -183,7 +199,10 @@ export class ScheduleBeat { ) this.cronJobs.set(record.id, task) - logger.debug(`[ScheduleBeat]: Registered cron job for schedule ${record.id} (${record.cronExpression} ${tz})`) + logger.debug( + `[ScheduleBeat]: Registered cron job for schedule ${record.id} ` + + `(${record.cronExpression}${hasL ? ` β†’ ${nodeCronExpression}` : ''} ${tz})` + ) } /** diff --git a/packages/server/src/services/schedule/utils.test.ts b/packages/server/src/services/schedule/utils.test.ts index 8717ec7de97..567255a792d 100644 --- a/packages/server/src/services/schedule/utils.test.ts +++ b/packages/server/src/services/schedule/utils.test.ts @@ -6,6 +6,8 @@ import { resolveScheduleCron, isScheduleInputValid, canScheduleEnable, + expandCronLForNodeCron, + cronDomMatchesNow, VisualPickerInput } from './utils' @@ -167,6 +169,39 @@ describe('validateCronExpression', () => { expect(result.valid).toBe(true) }) }) + + describe('`L` token (last day of month)', () => { + it('accepts standalone L in 5-field day-of-month field', () => { + expect(validateCronExpression('0 9 L * *')).toEqual({ valid: true }) + }) + + it('accepts L mixed with numeric days in 5-field DOM', () => { + expect(validateCronExpression('0 9 1,15,L * *')).toEqual({ valid: true }) + }) + + it('accepts L in 6-field DOM (position 4)', () => { + expect(validateCronExpression('0 0 9 L * *')).toEqual({ valid: true }) + }) + + it('rejects L in any field other than day-of-month (5-field)', () => { + // L in minute, hour, month, dow positions + expect(validateCronExpression('L * * * *').valid).toBe(false) + expect(validateCronExpression('* L * * *').valid).toBe(false) + expect(validateCronExpression('* * * L *').valid).toBe(false) + expect(validateCronExpression('* * * * L').valid).toBe(false) + }) + + it('rejects L in any field other than day-of-month (6-field)', () => { + // L in seconds and minutes positions of a 6-field cron + expect(validateCronExpression('L * * * * *').valid).toBe(false) + expect(validateCronExpression('* L * * * *').valid).toBe(false) + }) + + it('rejects malformed L tokens like LL or L5', () => { + expect(validateCronExpression('0 9 LL * *').valid).toBe(false) + expect(validateCronExpression('0 9 L5 * *').valid).toBe(false) + }) + }) }) // ─── computeNextRunAt ───────────────────────────────────────────────────────── @@ -303,6 +338,74 @@ describe('computeNextRunAt', () => { expect(next).not.toBeNull() expect(next!.getUTCMilliseconds()).toBe(0) }) + + // ── `L` token (last day of month) ────────────────────────────────── + + it('resolves L to Jan 31 (31-day month)', () => { + const ref = new Date('2025-01-15T00:00:00Z') + const next = computeNextRunAt('0 9 L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(31) + expect(next!.getUTCMonth()).toBe(0) // January + expect(next!.getUTCHours()).toBe(9) + }) + + it('resolves L to Apr 30 (30-day month)', () => { + const ref = new Date('2025-04-15T00:00:00Z') + const next = computeNextRunAt('0 9 L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(30) + expect(next!.getUTCMonth()).toBe(3) // April + }) + + it('resolves L to Feb 28 in a non-leap year', () => { + const ref = new Date('2025-02-10T00:00:00Z') + const next = computeNextRunAt('0 9 L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(28) + expect(next!.getUTCMonth()).toBe(1) // February + }) + + it('resolves L to Feb 29 in a leap year', () => { + const ref = new Date('2024-02-10T00:00:00Z') + const next = computeNextRunAt('0 9 L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(29) + expect(next!.getUTCMonth()).toBe(1) + }) + + it('rolls over to next month when current month`s last day has passed', () => { + // Jan 31 09:00 has just passed β†’ next L should be Feb 28 (2025 non-leap) + const ref = new Date('2025-01-31T10:00:00Z') + const next = computeNextRunAt('0 9 L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCMonth()).toBe(1) // February + expect(next!.getUTCDate()).toBe(28) + }) + + it('honours mixed list `15,L`: picks the earlier occurrence', () => { + // From Jan 1, 15,L resolves first to Jan 15 (not Jan 31) + const ref = new Date('2025-01-01T00:00:00Z') + const next = computeNextRunAt('0 9 15,L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(15) + }) + + it('honours mixed list `15,L`: jumps to month-end after the 15th', () => { + // From Jan 16, 15,L resolves to Jan 31 (last day) + const ref = new Date('2025-01-16T00:00:00Z') + const next = computeNextRunAt('0 9 15,L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(31) + }) + + it('honours mixed list `31,L`: jumps to month-end after the 31st', () => { + // From Jan 16, 31,L resolves to Jan 31 (last day) + const ref = new Date('2025-01-16T00:00:00Z') + const next = computeNextRunAt('0 9 31,L * *', 'UTC', ref) + expect(next).not.toBeNull() + expect(next!.getUTCDate()).toBe(31) + }) }) // ─── validateVisualPickerFields ─────────────────────────────────────────────── @@ -444,6 +547,19 @@ describe('validateVisualPickerFields', () => { it('accepts last day of month (31)', () => { expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: '31' })).toEqual({ valid: true }) }) + + it('accepts the L (last day of month) token', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: 'L' })).toEqual({ valid: true }) + }) + + it('accepts L mixed with numeric days', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: '1,15,L' })).toEqual({ valid: true }) + }) + + it('rejects malformed L tokens', () => { + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: 'LL' }).valid).toBe(false) + expect(validateVisualPickerFields({ ...base, scheduleOnDayOfMonth: 'l' }).valid).toBe(false) // lowercase not allowed + }) }) }) @@ -727,3 +843,132 @@ describe('canScheduleEnable', () => { }) }) }) + +// ─── expandCronLForNodeCron ─────────────────────────────────────────────────── + +describe('expandCronLForNodeCron', () => { + it('returns input verbatim when there is no L', () => { + const result = expandCronLForNodeCron('0 9 * * 1-5') + expect(result).toEqual({ expression: '0 9 * * 1-5', hasL: false }) + }) + + it('expands a standalone L in the day-of-month field to 28-31', () => { + const result = expandCronLForNodeCron('0 9 L * *') + expect(result).toEqual({ expression: '0 9 28-31 * *', hasL: true }) + }) + + it('expands L within a comma list, leaving other entries untouched', () => { + const result = expandCronLForNodeCron('0 9 1,15,L * *') + expect(result).toEqual({ expression: '0 9 1,15,28-31 * *', hasL: true }) + }) + + it('expands L correctly in a 6-field cron (DOM is at index 3)', () => { + const result = expandCronLForNodeCron('30 0 9 L * *') + expect(result).toEqual({ expression: '30 0 9 28-31 * *', hasL: true }) + }) + + it('does not touch L-like tokens in other positions', () => { + // The malformed expression is left alone (validation is the caller's job). + const result = expandCronLForNodeCron('L 9 * * *') + expect(result).toEqual({ expression: 'L 9 * * *', hasL: false }) + }) + + it('returns input verbatim when field count is not 5 or 6', () => { + // 4 fields β†’ not a valid cron, no expansion attempted + const result = expandCronLForNodeCron('0 9 L *') + expect(result.hasL).toBe(false) + expect(result.expression).toBe('0 9 L *') + }) + + it('only expands the standalone `L` part, not substrings like `L5` or `LL`', () => { + // These are not standalone "L"; they are passed through unchanged so the + // upstream validator can reject them. + const r1 = expandCronLForNodeCron('0 9 L5 * *') + expect(r1.hasL).toBe(false) + expect(r1.expression).toBe('0 9 L5 * *') + + const r2 = expandCronLForNodeCron('0 9 LL * *') + expect(r2.hasL).toBe(false) + expect(r2.expression).toBe('0 9 LL * *') + }) + + // ── deduplication of redundant numeric DOMs covered by `28-31` ───── + + it('drops standalone numeric days already covered by 28-31 (e.g. `31,L` β†’ `28-31`)', () => { + expect(expandCronLForNodeCron('0 9 31,L * *')).toEqual({ expression: '0 9 28-31 * *', hasL: true }) + }) + + it('drops every numeric day in [28,31] when combined with L', () => { + expect(expandCronLForNodeCron('0 9 28,29,30,31,L * *')).toEqual({ expression: '0 9 28-31 * *', hasL: true }) + }) + + it('drops ranges entirely contained in [28,31] when combined with L', () => { + expect(expandCronLForNodeCron('0 9 29-30,L * *')).toEqual({ expression: '0 9 28-31 * *', hasL: true }) + }) + + it('keeps numeric days outside [28,31] alongside the appended 28-31', () => { + expect(expandCronLForNodeCron('0 9 1,15,28,L * *')).toEqual({ expression: '0 9 1,15,28-31 * *', hasL: true }) + }) + + it('keeps partially-overlapping ranges verbatim (e.g. 25-29 is not fully inside [28,31])', () => { + // `25-29` partially overlaps with [28,31] but is left as-is β€” node-cron unions it with 28-31. + expect(expandCronLForNodeCron('0 9 25-29,L * *')).toEqual({ expression: '0 9 25-29,28-31 * *', hasL: true }) + }) +}) + +// ─── cronDomMatchesNow ──────────────────────────────────────────────────────── + +describe('cronDomMatchesNow', () => { + it('returns true when DOM field has no L (no filtering needed)', () => { + // A non-L expression: any date matches because the DOM field is `*`. + expect(cronDomMatchesNow('0 9 * * *', new Date('2025-04-15T09:00:00Z'), 'UTC')).toBe(true) + }) + + it('returns true on the actual last day of a 31-day month', () => { + // Jan has 31 days + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-01-31T09:00:00Z'), 'UTC')).toBe(true) + }) + + it('returns true on the last day of a 30-day month (Apr 30)', () => { + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-04-30T09:00:00Z'), 'UTC')).toBe(true) + }) + + it('returns false on day 30 of a 31-day month (not the last day)', () => { + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-01-30T09:00:00Z'), 'UTC')).toBe(false) + }) + + it('returns true on Feb 28 in a non-leap year', () => { + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-02-28T09:00:00Z'), 'UTC')).toBe(true) + }) + + it('returns false on Feb 28 in a leap year (Feb 29 is the actual last day)', () => { + expect(cronDomMatchesNow('0 9 L * *', new Date('2024-02-28T09:00:00Z'), 'UTC')).toBe(false) + }) + + it('returns true on Feb 29 in a leap year', () => { + expect(cronDomMatchesNow('0 9 L * *', new Date('2024-02-29T09:00:00Z'), 'UTC')).toBe(true) + }) + + it('honours timezone when resolving DOM', () => { + // 2025-02-01T03:00:00Z is still Jan 31 22:00 in America/New_York (UTC-5). + // For tz=America/New_York, the local DOM is 31 β†’ matches L on a 31-day month. + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-02-01T03:00:00Z'), 'America/New_York')).toBe(true) + // Same instant in UTC is Feb 1 β†’ not the last day. + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-02-01T03:00:00Z'), 'UTC')).toBe(false) + }) + + it('matches a numeric DOM entry alongside L', () => { + // `15,L` should match the 15th in any month + expect(cronDomMatchesNow('0 9 15,L * *', new Date('2025-04-15T09:00:00Z'), 'UTC')).toBe(true) + // …and the actual last day + expect(cronDomMatchesNow('0 9 15,L * *', new Date('2025-04-30T09:00:00Z'), 'UTC')).toBe(true) + // …but not the 16th + expect(cronDomMatchesNow('0 9 15,L * *', new Date('2025-04-16T09:00:00Z'), 'UTC')).toBe(false) + }) + + it('falls back to UTC when timezone is invalid', () => { + // Invalid timezone causes Intl.DateTimeFormat to throw; the catch block + // uses UTC date components. Jan 31 UTC is the last day of January. + expect(cronDomMatchesNow('0 9 L * *', new Date('2025-01-31T09:00:00Z'), 'Invalid/Zone')).toBe(true) + }) +}) diff --git a/packages/server/src/services/schedule/utils.ts b/packages/server/src/services/schedule/utils.ts index a1ad1ca1b04..f8ef2b6d0be 100644 --- a/packages/server/src/services/schedule/utils.ts +++ b/packages/server/src/services/schedule/utils.ts @@ -57,12 +57,14 @@ export const validateCronExpression = ( return n >= min && n <= max } - // Validate a single cron field: supports *, numbers, ranges (n-m), steps (*/s, n/s, n-m/s), and comma-separated lists - const validateCronField = (field: string, min: number, max: number): boolean => { + // Validate a single cron field: supports *, numbers, ranges (n-m), steps (*/s, n/s, n-m/s), and comma-separated lists. + // When `allowL` is true, also accepts the standalone `L` token (used for the day-of-month field to mean "last day of month"). + const validateCronField = (field: string, min: number, max: number, allowL: boolean = false): boolean => { const parts = field.split(',') if (parts.some((p) => p === '')) return false // catches leading/trailing/consecutive commas for (const part of parts) { + if (allowL && part === 'L') continue const slashIdx = part.indexOf('/') if (slashIdx !== -1) { const base = part.slice(0, slashIdx) @@ -90,8 +92,10 @@ export const validateCronExpression = ( // For 6-field cron, prepend an extra seconds range (same as minutes: 0-59) const ranges: Array<[number, number]> = fields.length === 6 ? [[0, 59], ...fieldRanges] : fieldRanges + // Day-of-month is at position 2 (5-field) or 3 (6-field). Allow `L` only there. + const domIndex = fields.length === 6 ? 3 : 2 for (let i = 0; i < fields.length; i++) { - if (!validateCronField(fields[i], ranges[i][0], ranges[i][1])) { + if (!validateCronField(fields[i], ranges[i][0], ranges[i][1], i === domIndex)) { return { valid: false, error: `Invalid cron field at position ${i + 1}: "${fields[i]}"` } } } @@ -194,6 +198,23 @@ function _matchCronField(field: string, value: number, min: number): boolean { return false } +/** + * Day-of-month matcher that additionally supports the `L` token, which fires only on the + * last day of the current month. Other parts (numbers, ranges, lists, steps) fall through + * to `_matchCronField`. + */ +function _matchDomField(field: string, dom: number, lastDay: number): boolean { + if (field === '*') return true + for (const part of field.split(',')) { + if (part === 'L') { + if (dom === lastDay) return true + continue + } + if (_matchCronField(part, dom, 1)) return true + } + return false +} + interface _ParsedCronFields { minuteField: string hourField: string @@ -220,7 +241,7 @@ function _parseCronFields(expression: string): _ParsedCronFields { * Both `parsed` and `fmt` should be created once outside any hot loop. */ function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.DateTimeFormat): boolean { - let minute: number, hour: number, dom: number, month: number, dow: number + let minute: number, hour: number, dom: number, month: number, dow: number, year: number try { const parts = fmt.formatToParts(date) const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) @@ -229,6 +250,7 @@ function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.Dat hour = get('hour') % 24 dom = get('day') month = get('month') + year = get('year') dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayStr) if (dow === -1) dow = date.getUTCDay() } catch { @@ -236,13 +258,17 @@ function _cronMatchesParsed(parsed: _ParsedCronFields, date: Date, fmt: Intl.Dat hour = date.getUTCHours() dom = date.getUTCDate() month = date.getUTCMonth() + 1 + year = date.getUTCFullYear() dow = date.getUTCDay() } + // Last day of the (TZ-local) month: `new Date(year, month, 0)` rolls to the last day of `month` + // because day 0 of the next month equals the last day of the current month. + const lastDay = new Date(year, month, 0).getDate() const dowMatches = _matchCronField(parsed.dowField, dow, 0) || (dow === 0 && _matchCronField(parsed.dowField, 7, 0)) return ( _matchCronField(parsed.minuteField, minute, 0) && _matchCronField(parsed.hourField, hour, 0) && - _matchCronField(parsed.domField, dom, 1) && + _matchDomField(parsed.domField, dom, lastDay) && _matchCronField(parsed.monthField, month, 1) && dowMatches ) @@ -324,6 +350,97 @@ export const computeNextRunAt = (cronExpression: string, timezone: string = 'UTC return null } +// ─── node-cron compatibility helpers (`L` token) ────────────────────────────── + +/** + * `node-cron` does not understand the `L` token (last day of month). To stay + * compatible across both BullMQ (cron-parser, supports `L`) and node-cron + * scheduling backends, expand any standalone `L` part in the day-of-month + * field to the candidate range `28-31`, while leaving the rest of the + * expression untouched. + * + * The expanded expression is *only* meant to be handed to `node-cron`; the + * original (un-expanded) expression should still be used for any actual + * "does this date match?" decision via {@link cronDomMatchesNow}. + * + * @returns `{ expression, hasL }` β€” `expression` is the expanded cron string + * (or the input verbatim if there was nothing to expand); `hasL` indicates + * whether the input contained `L` and therefore needs runtime DOM filtering. + */ +export const expandCronLForNodeCron = (cronExpression: string): { expression: string; hasL: boolean } => { + const fields = cronExpression.trim().split(/\s+/) + if (fields.length !== 5 && fields.length !== 6) { + return { expression: cronExpression, hasL: false } + } + const domIdx = fields.length === 6 ? 3 : 2 + const domField = fields[domIdx] + const parts = domField.split(',') + const hasL = parts.includes('L') + if (!hasL) return { expression: cronExpression, hasL: false } + + // L expands to `28-31`, so drop any user-specified parts that are already + // covered by that range to avoid redundant entries like `31,28-31`. + // Ranges/steps that aren't fully inside [28, 31] are left untouched β€” + // node-cron will simply union them with the appended `28-31` part. + const kept: string[] = [] + for (const p of parts) { + if (p === 'L') continue + if (/^\d+$/.test(p)) { + const n = parseInt(p, 10) + if (n >= 28 && n <= 31) continue + } else { + const rangeMatch = /^(\d+)-(\d+)$/.exec(p) + if (rangeMatch) { + const a = parseInt(rangeMatch[1], 10) + const b = parseInt(rangeMatch[2], 10) + if (a >= 28 && b <= 31) continue + } + } + kept.push(p) + } + kept.push('28-31') + fields[domIdx] = kept.join(',') + return { expression: fields.join(' '), hasL: true } +} + +/** + * Verify that the given `date`'s day-of-month (interpreted in `timezone`) + * satisfies the day-of-month field of the *original* cron expression, + * including the `L` token. Used to filter false-positive fires from + * `node-cron` after expanding `L` β†’ `28-31` via {@link expandCronLForNodeCron}. + * + * Returns `true` when the DOM matches (fire is legitimate) or when the + * original expression contains no `L` (no filtering needed). + */ +export const cronDomMatchesNow = (cronExpression: string, date: Date = new Date(), timezone: string = 'UTC'): boolean => { + const parsed = _parseCronFields(cronExpression) + + // Extract DOM, month, year in the schedule's timezone so leap-year and + // DST month boundaries are honoured. + let dom: number, month: number, year: number + try { + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric' + }) + const parts = fmt.formatToParts(date) + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10) + dom = get('day') + month = get('month') + year = get('year') + } catch { + dom = date.getUTCDate() + month = date.getUTCMonth() + 1 + year = date.getUTCFullYear() + } + + // Day 0 of next month == last day of current month. + const lastDay = new Date(year, month, 0).getDate() + return _matchDomField(parsed.domField, dom, lastDay) +} + // ─── Visual Picker helpers ──────────────────────────────────────────────────── export interface VisualPickerInput { @@ -395,9 +512,10 @@ export const validateVisualPickerFields = (input: VisualPickerInput): { valid: b .map((d) => d.trim()) .filter((d) => d !== '') for (const d of days) { + if (d === 'L') continue // "Last Day of month" token const n = Number(d) if (isNaN(n) || !Number.isInteger(n) || n < 1 || n > 31) { - return { valid: false, error: `Invalid day of month value: ${d} (expected 1-31)` } + return { valid: false, error: `Invalid day of month value: ${d} (expected 1-31 or L)` } } } } diff --git a/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx index 0a260db6432..00b3de84329 100644 --- a/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx +++ b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx @@ -3,7 +3,8 @@ import PropTypes from 'prop-types' import { Box, Chip } from '@mui/material' import { useTheme } from '@mui/material/styles' -const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1) +const LAST_DAY_TOKEN = 'L' +const DAYS_OF_MONTH = [...Array.from({ length: 31 }, (_, i) => String(i + 1)), LAST_DAY_TOKEN] export const MonthDaysPicker = ({ value, onChange, disabled = false }) => { const theme = useTheme() @@ -19,10 +20,18 @@ export const MonthDaysPicker = ({ value, onChange, disabled = false }) => { return [] } - const [selected, setSelected] = useState(parseValue(value)) + // Sort numeric days ascending, keep "L" (last day) at the end. + const sortDays = (arr) => + [...arr].sort((a, b) => { + if (a === LAST_DAY_TOKEN) return 1 + if (b === LAST_DAY_TOKEN) return -1 + return Number(a) - Number(b) + }) + + const [selected, setSelected] = useState(sortDays(parseValue(value))) useEffect(() => { - setSelected(parseValue(value)) + setSelected(sortDays(parseValue(value))) }, [value]) const toggle = (day) => { @@ -34,11 +43,41 @@ export const MonthDaysPicker = ({ value, onChange, disabled = false }) => { } else { next = [...selected, dayStr] } - next.sort((a, b) => Number(a) - Number(b)) + next = sortDays(next) setSelected(next) onChange(next.join(',')) } + const renderChip = (valueToken, label) => { + const isSelected = selected.includes(valueToken) + const isLastDay = valueToken === LAST_DAY_TOKEN + return ( + toggle(valueToken)} + sx={{ + cursor: disabled ? 'default' : 'pointer', + minWidth: 32, + gridColumn: isLastDay ? 'span 2' : 'span 1', + fontWeight: isSelected ? 600 : 400, + borderWidth: '1.5px', + borderStyle: 'solid', + borderColor: isSelected ? theme.palette.primary.main : theme.palette.grey[400], + backgroundColor: isSelected ? theme.palette.primary.main + '20' : 'transparent', + color: isSelected ? theme.palette.primary.main : theme.palette.text.primary, + '&:hover': disabled + ? {} + : { + backgroundColor: isSelected ? theme.palette.primary.main + '35' : theme.palette.grey[200] + } + }} + /> + ) + } + return ( { gap: 0.5 }} > - {DAYS_OF_MONTH.map((day) => { - const dayStr = String(day) - const isSelected = selected.includes(dayStr) - return ( - toggle(day)} - sx={{ - cursor: disabled ? 'default' : 'pointer', - minWidth: 32, - fontWeight: isSelected ? 600 : 400, - borderWidth: '1.5px', - borderStyle: 'solid', - borderColor: isSelected ? theme.palette.primary.main : theme.palette.grey[400], - backgroundColor: isSelected ? theme.palette.primary.main + '20' : 'transparent', - color: isSelected ? theme.palette.primary.main : theme.palette.text.primary, - '&:hover': disabled - ? {} - : { - backgroundColor: isSelected ? theme.palette.primary.main + '35' : theme.palette.grey[200] - } - }} - /> - ) - })} + {DAYS_OF_MONTH.map((day) => renderChip(day, day === LAST_DAY_TOKEN ? 'Last Day' : day))} ) } From 7f40191e1a8ab76f9ea33f647136bc15bc298c78 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 30 Apr 2026 23:57:31 +0100 Subject: [PATCH 15/15] - fix(start-node): persist declared defaults of newly-visible fields so saves don't drop required values - update ui for last day to include hints and next run datetime --- .../src/core/utils/fieldVisibility.test.ts | 130 ++++++++++++- .../src/core/utils/fieldVisibility.ts | 33 +++- packages/agentflow/src/core/utils/index.ts | 2 +- .../features/node-editor/EditNodeDialog.tsx | 6 +- packages/agentflow/src/index.ts | 7 +- .../ui-component/picker/MonthDaysPicker.jsx | 17 +- packages/ui/src/utils/genericHelper.js | 30 ++- packages/ui/src/utils/genericHelper.test.js | 174 ++++++++++++++++++ .../src/views/agentflowsv2/EditNodeDialog.jsx | 6 +- .../views/schedule/ScheduleHistoryDrawer.jsx | 45 ++++- 10 files changed, 430 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/utils/genericHelper.test.js diff --git a/packages/agentflow/src/core/utils/fieldVisibility.test.ts b/packages/agentflow/src/core/utils/fieldVisibility.test.ts index 45bf66488d9..267de648cea 100644 --- a/packages/agentflow/src/core/utils/fieldVisibility.test.ts +++ b/packages/agentflow/src/core/utils/fieldVisibility.test.ts @@ -1,6 +1,12 @@ import type { InputParam } from '../types' -import { conditionMatches, evaluateFieldVisibility, evaluateParamVisibility, stripHiddenFieldValues } from './fieldVisibility' +import { + applyVisibleFieldDefaults, + conditionMatches, + evaluateFieldVisibility, + evaluateParamVisibility, + stripHiddenFieldValues +} from './fieldVisibility' const makeParam = (overrides: Partial = {}): InputParam => ({ id: 'p1', @@ -256,3 +262,125 @@ describe('stripHiddenFieldValues', () => { expect(result).toHaveProperty('mode', 'api') }) }) + +describe('evaluateFieldVisibility – declared defaults of sibling fields', () => { + const scheduleTypeParam = makeParam({ + name: 'scheduleType', + default: 'visualPicker', + show: { startInputType: 'scheduleInput' } + }) + const frequencyParam = makeParam({ + name: 'scheduleFrequency', + show: { startInputType: 'scheduleInput', scheduleType: 'visualPicker' } + }) + const defaultInputParam = makeParam({ + name: 'scheduleDefaultInput', + show: { startInputType: 'scheduleInput', scheduleInputMode: 'text' } + }) + const scheduleInputModeParam = makeParam({ + name: 'scheduleInputMode', + default: 'text', + show: { startInputType: 'scheduleInput' } + }) + + const params = [scheduleTypeParam, scheduleInputModeParam, frequencyParam, defaultInputParam] + + it('shows fields whose `show` references a sibling default value, even if the sibling key is absent', () => { + const inputs = { startInputType: 'scheduleInput' } + const result = evaluateFieldVisibility(params, inputs) + const byName = Object.fromEntries(result.map((p) => [p.name, p.display])) + + expect(byName.scheduleType).toBe(true) + expect(byName.scheduleInputMode).toBe(true) + expect(byName.scheduleFrequency).toBe(true) + expect(byName.scheduleDefaultInput).toBe(true) + }) + + it('explicit value overrides declared default', () => { + // User explicitly chose cronExpression β€” Frequency must hide. + const inputs = { startInputType: 'scheduleInput', scheduleType: 'cronExpression' } + const result = evaluateFieldVisibility(params, inputs) + const byName = Object.fromEntries(result.map((p) => [p.name, p.display])) + + expect(byName.scheduleFrequency).toBe(false) + }) + + it('does not synthesize defaults for fields that have no `default`', () => { + // No declared default => stays missing => sibling show against it fails. + const sibling = makeParam({ name: 'sib', show: { other: 'expected' } }) + const referenced = makeParam({ name: 'other' /* no default */ }) + const result = evaluateFieldVisibility([referenced, sibling], {}) + expect(result.find((p) => p.name === 'sib')!.display).toBe(false) + }) +}) + +describe('applyVisibleFieldDefaults', () => { + const buildParams = (): InputParam[] => [ + makeParam({ + name: 'scheduleType', + default: 'visualPicker', + show: { startInputType: 'scheduleInput' } + }), + makeParam({ + name: 'scheduleInputMode', + default: 'text', + show: { startInputType: 'scheduleInput' } + }), + // Hidden in this scenario β€” its default must NOT be merged. + makeParam({ + name: 'formTitle', + default: 'Untitled Form', + show: { startInputType: 'formInput' } + }), + // Visible but no default β€” stays missing. + makeParam({ + name: 'scheduleFrequency', + show: { startInputType: 'scheduleInput', scheduleType: 'visualPicker' } + }) + ] + + it('writes declared defaults for currently visible fields whose value is missing', () => { + const params = buildParams() + const result = applyVisibleFieldDefaults(params, { startInputType: 'scheduleInput' }) + + expect(result.scheduleType).toBe('visualPicker') + expect(result.scheduleInputMode).toBe('text') + }) + + it('does not synthesize defaults for hidden fields', () => { + const params = buildParams() + const result = applyVisibleFieldDefaults(params, { startInputType: 'scheduleInput' }) + + expect(result).not.toHaveProperty('formTitle') + }) + + it('does not synthesize defaults for fields without a `default`', () => { + const params = buildParams() + const result = applyVisibleFieldDefaults(params, { startInputType: 'scheduleInput' }) + + expect(result).not.toHaveProperty('scheduleFrequency') + }) + + it('preserves existing values, including falsy ones (empty string, false, 0)', () => { + const params: InputParam[] = [ + makeParam({ name: 'a', default: 'fallback' }), + makeParam({ name: 'b', default: 'fallback' }), + makeParam({ name: 'c', default: 'fallback' }), + makeParam({ name: 'd', default: 'fallback' }) + ] + const result = applyVisibleFieldDefaults(params, { a: '', b: false, c: 0, d: null }) + + expect(result.a).toBe('') + expect(result.b).toBe(false) + expect(result.c).toBe(0) + expect(result.d).toBeNull() + }) + + it('does not mutate the input map', () => { + const params = buildParams() + const inputs = { startInputType: 'scheduleInput' } + const inputsBefore = { ...inputs } + applyVisibleFieldDefaults(params, inputs) + expect(inputs).toEqual(inputsBefore) + }) +}) diff --git a/packages/agentflow/src/core/utils/fieldVisibility.ts b/packages/agentflow/src/core/utils/fieldVisibility.ts index c2b6c8fd950..60250ebfdfb 100644 --- a/packages/agentflow/src/core/utils/fieldVisibility.ts +++ b/packages/agentflow/src/core/utils/fieldVisibility.ts @@ -127,17 +127,45 @@ export function evaluateParamVisibility(param: InputParam, inputValues: Record): Record { + const merged: Record = { ...inputValues } + for (const param of params) { + if (param.default === undefined) continue + if (merged[param.name] === undefined) { + merged[param.name] = param.default + } + } + return merged +} + /** * Evaluate visibility for all params, returning new param objects with computed `display`. * Does not mutate the originals. */ export function evaluateFieldVisibility(params: InputParam[], inputValues: Record, arrayIndex?: number): InputParam[] { + const effectiveInputs = inputValuesWithDeclaredDefaults(params, inputValues) return params.map((param) => ({ ...param, - display: evaluateParamVisibility(param, inputValues, arrayIndex) + display: evaluateParamVisibility(param, effectiveInputs, arrayIndex) })) } +export function applyVisibleFieldDefaults( + params: InputParam[], + inputValues: Record, + arrayIndex?: number +): Record { + const effectiveInputs = inputValuesWithDeclaredDefaults(params, inputValues) + const result: Record = { ...inputValues } + for (const param of params) { + if (param.default === undefined) continue + if (result[param.name] !== undefined) continue + if (!evaluateParamVisibility(param, effectiveInputs, arrayIndex)) continue + result[param.name] = param.default + } + return result +} + /** * Return a copy of inputValues with keys for hidden params removed. */ @@ -146,9 +174,10 @@ export function stripHiddenFieldValues( inputValues: Record, arrayIndex?: number ): Record { + const effectiveInputs = inputValuesWithDeclaredDefaults(params, inputValues) const result: Record = { ...inputValues } for (const param of params) { - if (!evaluateParamVisibility(param, inputValues, arrayIndex)) { + if (!evaluateParamVisibility(param, effectiveInputs, arrayIndex)) { delete result[param.name] } } diff --git a/packages/agentflow/src/core/utils/index.ts b/packages/agentflow/src/core/utils/index.ts index c99b54a931c..a1303efc04b 100644 --- a/packages/agentflow/src/core/utils/index.ts +++ b/packages/agentflow/src/core/utils/index.ts @@ -5,7 +5,7 @@ export { getUniqueNodeId, getUniqueNodeLabel, initNode, resolveNodeType } from ' export { generateExportFlowData } from './flowExport' // Field visibility engine -export { evaluateFieldVisibility, evaluateParamVisibility, stripHiddenFieldValues } from './fieldVisibility' +export { applyVisibleFieldDefaults, evaluateFieldVisibility, evaluateParamVisibility, stripHiddenFieldValues } from './fieldVisibility' // Dynamic output anchor utilities export { buildDynamicOutputAnchors, parseOutputHandleIndex } from './dynamicOutputAnchors' diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx index 36db05caccc..db643db5ce7 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx @@ -7,7 +7,7 @@ import { IconCheck, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-reac import { ConditionBuilder, MessagesInput, NodeInputHandler, ScenariosInput, StructuredOutputBuilder } from '@/atoms' import type { EditDialogProps, InputParam, NodeData } from '@/core/types' -import { buildDynamicOutputAnchors, evaluateFieldVisibility } from '@/core/utils' +import { applyVisibleFieldDefaults, buildDynamicOutputAnchors, evaluateFieldVisibility } from '@/core/utils' import { useAgentflowContext, useConfigContext } from '@/infrastructure/store' import { AsyncInput } from './AsyncInput' @@ -103,10 +103,10 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog const onCustomDataChange = ({ inputParam, newValue }: { inputParam: InputParam; newValue: unknown }) => { if (!data) return - const updatedInputValues = { + const updatedInputValues = applyVisibleFieldDefaults(inputParams, { ...data.inputs, [inputParam.name]: newValue - } + }) const updatedParams = evaluateFieldVisibility(inputParams, updatedInputValues) setInputParams(updatedParams) diff --git a/packages/agentflow/src/index.ts b/packages/agentflow/src/index.ts index e11d95cf8e3..8edbb87754f 100644 --- a/packages/agentflow/src/index.ts +++ b/packages/agentflow/src/index.ts @@ -62,5 +62,10 @@ export type { // Utilities (for advanced usage) export { filterNodesByComponents, isAgentflowNode } from './core/node-catalog' export { AGENTFLOW_ICONS, DEFAULT_AGENTFLOW_NODES, getAgentflowIcon, getNodeColor } from './core/node-config' -export { evaluateFieldVisibility, evaluateParamVisibility, stripHiddenFieldValues } from './core/utils/fieldVisibility' +export { + applyVisibleFieldDefaults, + evaluateFieldVisibility, + evaluateParamVisibility, + stripHiddenFieldValues +} from './core/utils/fieldVisibility' export { validateFlow } from './core/validation' diff --git a/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx index 00b3de84329..e481f1ed012 100644 --- a/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx +++ b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import PropTypes from 'prop-types' -import { Box, Chip } from '@mui/material' +import { Box, Chip, Tooltip } from '@mui/material' import { useTheme } from '@mui/material/styles' const LAST_DAY_TOKEN = 'L' @@ -51,7 +51,7 @@ export const MonthDaysPicker = ({ value, onChange, disabled = false }) => { const renderChip = (valueToken, label) => { const isSelected = selected.includes(valueToken) const isLastDay = valueToken === LAST_DAY_TOKEN - return ( + const chip = ( { }} /> ) + if (isLastDay) { + return ( + + {chip} + + ) + } + return chip } return ( diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index 08297ce2295..d9f464828a4 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -1281,8 +1281,21 @@ const _showHideOperation = (nodeData, inputParam, displayType, index) => { }) } +const _inputsWithDeclaredDefaults = (params, inputs) => { + const merged = { ...(inputs ?? {}) } + for (let i = 0; i < params.length; i += 1) { + const param = params[i] + if (!param || param.default === undefined) continue + if (merged[param.name] === undefined) { + merged[param.name] = param.default + } + } + return merged +} + export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex) => { const params = overrideParams ?? nodeData[inputType] ?? [] + const effectiveNodeData = { ...nodeData, inputs: _inputsWithDeclaredDefaults(params, nodeData.inputs) } for (let i = 0; i < params.length; i += 1) { const inputParam = params[i] @@ -1291,10 +1304,10 @@ export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex) inputParam.display = true if (inputParam.show) { - _showHideOperation(nodeData, inputParam, 'show', arrayIndex) + _showHideOperation(effectiveNodeData, inputParam, 'show', arrayIndex) } if (inputParam.hide) { - _showHideOperation(nodeData, inputParam, 'hide', arrayIndex) + _showHideOperation(effectiveNodeData, inputParam, 'hide', arrayIndex) } } @@ -1308,3 +1321,16 @@ export const showHideInputParams = (nodeData) => { export const showHideInputAnchors = (nodeData) => { return showHideInputs(nodeData, 'inputAnchors') } + +export const applyVisibleInputDefaults = (params, inputs) => { + const result = { ...(inputs ?? {}) } + const evaluated = showHideInputs({ inputs: result }, null, params) + for (let i = 0; i < evaluated.length; i += 1) { + const param = evaluated[i] + if (!param || param.default === undefined) continue + if (param.display === false) continue + if (result[param.name] !== undefined) continue + result[param.name] = param.default + } + return result +} diff --git a/packages/ui/src/utils/genericHelper.test.js b/packages/ui/src/utils/genericHelper.test.js new file mode 100644 index 00000000000..203b91d02b0 --- /dev/null +++ b/packages/ui/src/utils/genericHelper.test.js @@ -0,0 +1,174 @@ +import { applyVisibleInputDefaults, showHideInputs } from './genericHelper' + +describe('showHideInputs – declared defaults of sibling fields', () => { + const buildParams = () => [ + { + label: 'Schedule Type', + name: 'scheduleType', + type: 'options', + default: 'visualPicker', + show: { startInputType: 'scheduleInput' } + }, + { + label: 'Schedule Input Mode', + name: 'scheduleInputMode', + type: 'options', + default: 'text', + show: { startInputType: 'scheduleInput' } + }, + { + label: 'Frequency', + name: 'scheduleFrequency', + type: 'options', + show: { startInputType: 'scheduleInput', scheduleType: 'visualPicker' } + }, + { + label: 'Default Input', + name: 'scheduleDefaultInput', + type: 'string', + show: { startInputType: 'scheduleInput', scheduleInputMode: 'text' } + } + ] + + it('shows fields whose `show` references a sibling default value, even if the sibling key is absent', () => { + const nodeData = { + inputParams: buildParams(), + inputs: { startInputType: 'scheduleInput' } + } + + const result = showHideInputs(nodeData, 'inputParams') + const byName = Object.fromEntries(result.map((p) => [p.name, p.display])) + + expect(byName.scheduleType).toBe(true) + expect(byName.scheduleInputMode).toBe(true) + expect(byName.scheduleFrequency).toBe(true) + expect(byName.scheduleDefaultInput).toBe(true) + }) + + it('explicit value overrides declared default', () => { + const nodeData = { + inputParams: buildParams(), + inputs: { startInputType: 'scheduleInput', scheduleType: 'cronExpression' } + } + + const result = showHideInputs(nodeData, 'inputParams') + const byName = Object.fromEntries(result.map((p) => [p.name, p.display])) + + expect(byName.scheduleFrequency).toBe(false) + // scheduleInputMode default still applies β€” Default Input stays visible. + expect(byName.scheduleDefaultInput).toBe(true) + }) + + it('does not synthesize defaults for fields without a declared `default`', () => { + const params = [ + { label: 'Other', name: 'other', type: 'string' /* no default */ }, + { label: 'Sib', name: 'sib', type: 'string', show: { other: 'expected' } } + ] + const nodeData = { inputParams: params, inputs: {} } + + const result = showHideInputs(nodeData, 'inputParams') + const byName = Object.fromEntries(result.map((p) => [p.name, p.display])) + + expect(byName.sib).toBe(false) + }) + + it('keeps Form Input fields hidden when type switches to scheduleInput', () => { + // Sanity: the fix should not accidentally make form-input fields visible + // after switching away from formInput. + const params = [ + ...buildParams(), + { + label: 'Form Title', + name: 'formTitle', + type: 'string', + show: { startInputType: 'formInput' } + } + ] + const nodeData = { + inputParams: params, + // Lingering form values from before the type switch: + inputs: { startInputType: 'scheduleInput', formTitle: 'leftover' } + } + + const result = showHideInputs(nodeData, 'inputParams') + const byName = Object.fromEntries(result.map((p) => [p.name, p.display])) + + expect(byName.formTitle).toBe(false) + }) +}) + +describe('applyVisibleInputDefaults', () => { + const buildParams = () => [ + { + name: 'scheduleType', + type: 'options', + default: 'visualPicker', + show: { startInputType: 'scheduleInput' } + }, + { + name: 'scheduleInputMode', + type: 'options', + default: 'text', + show: { startInputType: 'scheduleInput' } + }, + // Hidden in this scenario β€” its default must NOT be merged. + { + name: 'formTitle', + type: 'string', + default: 'Untitled Form', + show: { startInputType: 'formInput' } + }, + // Visible but no default β€” stays missing. + { + name: 'scheduleFrequency', + type: 'options', + show: { startInputType: 'scheduleInput', scheduleType: 'visualPicker' } + } + ] + + it('writes declared defaults for currently visible fields whose value is missing', () => { + const result = applyVisibleInputDefaults(buildParams(), { startInputType: 'scheduleInput' }) + + expect(result.scheduleType).toBe('visualPicker') + expect(result.scheduleInputMode).toBe('text') + }) + + it('does not synthesize defaults for hidden fields', () => { + const result = applyVisibleInputDefaults(buildParams(), { startInputType: 'scheduleInput' }) + + expect(result).not.toHaveProperty('formTitle') + }) + + it('does not synthesize defaults for fields without a `default`', () => { + const result = applyVisibleInputDefaults(buildParams(), { startInputType: 'scheduleInput' }) + + expect(result).not.toHaveProperty('scheduleFrequency') + }) + + it('preserves existing values, including falsy ones (empty string, false, 0, null)', () => { + const params = [ + { name: 'a', type: 'string', default: 'fallback' }, + { name: 'b', type: 'boolean', default: 'fallback' }, + { name: 'c', type: 'number', default: 'fallback' }, + { name: 'd', type: 'string', default: 'fallback' } + ] + const result = applyVisibleInputDefaults(params, { a: '', b: false, c: 0, d: null }) + + expect(result.a).toBe('') + expect(result.b).toBe(false) + expect(result.c).toBe(0) + expect(result.d).toBeNull() + }) + + it('does not mutate the input map', () => { + const inputs = { startInputType: 'scheduleInput' } + const inputsBefore = { ...inputs } + applyVisibleInputDefaults(buildParams(), inputs) + expect(inputs).toEqual(inputsBefore) + }) + + it('handles undefined or null inputs gracefully', () => { + expect(() => applyVisibleInputDefaults(buildParams(), undefined)).not.toThrow() + expect(() => applyVisibleInputDefaults(buildParams(), null)).not.toThrow() + }) +}) diff --git a/packages/ui/src/views/agentflowsv2/EditNodeDialog.jsx b/packages/ui/src/views/agentflowsv2/EditNodeDialog.jsx index ce8469d953a..e6567b7605d 100644 --- a/packages/ui/src/views/agentflowsv2/EditNodeDialog.jsx +++ b/packages/ui/src/views/agentflowsv2/EditNodeDialog.jsx @@ -9,7 +9,7 @@ import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions' import { IconPencil, IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react' import { useTheme } from '@mui/material/styles' import { flowContext } from '@/store/context/ReactFlowContext' -import { showHideInputParams } from '@/utils/genericHelper' +import { applyVisibleInputDefaults, showHideInputParams } from '@/utils/genericHelper' const EditNodeDialog = ({ show, dialogProps, onCancel }) => { const portalElement = document.getElementById('portal') @@ -45,10 +45,10 @@ const EditNodeDialog = ({ show, dialogProps, onCancel }) => { reactFlowInstance.setNodes((nds) => nds.map((node) => { if (node.id === nodeId) { - const updatedInputs = { + const updatedInputs = applyVisibleInputDefaults(node.data.inputParams, { ...node.data.inputs, [inputParam.name]: newValue - } + }) const updatedInputParams = showHideInputParams({ ...node.data, diff --git a/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx index b3807159660..a314bc21d04 100644 --- a/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx +++ b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx @@ -119,6 +119,30 @@ const StyledTableRow = styled(TableRow)(({ theme, clickable }) => ({ const relTime = (date) => (date ? moment(date).fromNow() : 'β€”') const fmtDate = (date) => (date ? moment(date).format('YYYY-MM-DD HH:mm:ss') : 'β€”') +// Formats a date in the given IANA timezone using Intl (no moment-timezone dependency). +// Falls back to local-time formatting if the timezone is invalid or omitted. +const fmtDateInTz = (date, timezone) => { + if (!date) return 'β€”' + const d = new Date(date) + if (isNaN(d.getTime())) return 'β€”' + try { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone || undefined, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + // en-CA produces "YYYY-MM-DD, HH:mm:ss" β€” strip the comma for a cleaner timestamp. + return fmt.format(d).replace(',', '') + } catch { + return fmtDate(date) + } +} + const fmtNextRun = (date) => { if (!date) return { text: 'β€”', overdue: false } const m = moment(date) @@ -516,17 +540,28 @@ const ScheduleHistoryDrawer = ({ open, chatflowid, onClose }) => { return β€” } const { text, overdue } = fmtNextRun(nextRunAt) + const tz = record?.timezone || 'UTC' + const exactInTz = fmtDateInTz(nextRunAt, tz) + const exactLocal = fmtDate(nextRunAt) return ( - - {text} - + + + {text} + + + {exactInTz} ({tz}) + + ) })()}