diff --git a/docker/.env.example b/docker/.env.example
index 7c02bdfa4d7..f40572a5ea8 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -208,3 +208,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 101ea470b3c..b31d5c0986e 100644
--- a/docker/docker-compose-queue-prebuilt.yml
+++ b/docker/docker-compose-queue-prebuilt.yml
@@ -160,6 +160,9 @@ services:
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
+
+ # 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 6eec369ebe0..03793fabc11 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -145,6 +145,9 @@ services:
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
+
+ # 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 9bcfa672b4a..94818234e8b 100644
--- a/docker/worker/docker-compose.yml
+++ b/docker/worker/docker-compose.yml
@@ -144,6 +144,9 @@ services:
- TRUST_PROXY=${TRUST_PROXY}
- OAUTH2_SECURITY_CHECK=${OAUTH2_SECURITY_CHECK}
- OAUTH2_ALLOWED_TOKEN_DOMAINS=${OAUTH2_ALLOWED_TOKEN_DOMAINS}
+
+ # SCHEDULE
+ - MIN_SCHEDULE_INTERVAL_SECONDS=${MIN_SCHEDULE_INTERVAL_SECONDS}
ports:
- '${WORKER_PORT}:${WORKER_PORT}'
healthcheck:
diff --git a/packages/agentflow/README.md b/packages/agentflow/README.md
index 97f17c36b79..f323f76411f 100644
--- a/packages/agentflow/README.md
+++ b/packages/agentflow/README.md
@@ -1,270 +1,270 @@
-# @flowiseai/agentflow
-
-[](https://www.npmjs.com/package/@flowiseai/agentflow)
-[](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
-
-> Embeddable React component for building and visualizing AI agent workflows
-
-## Status: Dev
-
-**Current version: `0.0.0-dev.13`**
-
-This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use.
-
-**What works today:**
-
-- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end)
-- Connection validation, flow validation, and export
-- Async option loading (models, credentials, tools)
-- Variable picker and field visibility conditions
-- AI flow generation from natural language
-- Dark/light mode theming
-- Read-only mode and custom rendering via render props
-
-## Overview
-
-`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
-
-## Features
-
-- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls
-- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow
-- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options)
-- **Credential Management** — Create and edit credentials inline from the node editor
-- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded)
-- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder
-- **AI Flow Generator** — Generate flows from natural language descriptions with model selection
-- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback
-- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables
-- **Read-Only Mode** — Disable editing for view-only embedding
-- **Custom Rendering** — Replace the default header and node palette with your own components via render props
-- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`)
-- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback
-- **Keyboard Shortcuts** — Cmd/Ctrl+S to save
-
-## Installation
-
-```bash
-pnpm add @flowiseai/agentflow
-```
-
-**Peer Dependencies:**
-
-```bash
-pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
-```
-
-## Basic Usage
-
-```tsx
-import { Agentflow } from '@flowiseai/agentflow'
-
-import '@flowiseai/agentflow/flowise.css'
-
-export default function App() {
- return (
-
- )
-}
-```
-
-### With Initial Flow Data and Callbacks
-
-```tsx
-import { useRef } from 'react'
-
-import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
-
-import '@flowiseai/agentflow/flowise.css'
-
-export default function App() {
- const ref = useRef(null)
-
- const initialFlow: FlowData = {
- nodes: [
- {
- id: 'startAgentflow_0',
- type: 'agentflowNode',
- position: { x: 100, y: 100 },
- data: {
- id: 'startAgentflow_0',
- name: 'startAgentflow',
- label: 'Start',
- color: '#7EE787',
- hideInput: true,
- outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
- }
- }
- ],
- edges: [],
- viewport: { x: 0, y: 0, zoom: 1 }
- }
-
- return (
-
-
console.log('Flow changed:', flow)}
- onSave={(flow) => console.log('Flow saved:', flow)}
- />
-
- )
-}
-```
-
-### More Examples
-
-The [examples app](./examples/README.md) includes working demos for:
-
-- **Variable usage** — `{{variable}}` syntax, variable picker, available sources
-- **Async options** — Loading models, tools, and credentials from the API
-- **Status indicators** — Node execution states (running, finished, error, stopped)
-- **Field visibility** — Show/hide conditions on node inputs
-- **State management** — Dirty tracking, flow change callbacks
-- **Dark mode** — Light/dark theme toggle
-- **Custom UI** — Custom header and node palette via render props
-- **Filtered components** — Restricting available node types with presets
-- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls
-
-Run `cd examples && npm install && npm run dev` to try them locally.
-
-## Props
-
-
-| Prop | Type | Default | Description |
-| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
-| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
-| `token` | `string` | — | Authentication token for API calls |
-| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. |
-| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
-| `flowId` | `string` | — | Flow identifier (reserved for future use) |
-| `components` | `string[]` | — | Restrict which node types appear in the palette |
-| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
-| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
-| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
-| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
-| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
-| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
-| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
-| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
-| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. |
-| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
-| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
-
-### Imperative Methods (via `ref`)
-
-| Method | Return Type | Description |
-| ------------------------ | ------------------------- | ------------------------------------- |
-| `getFlow()` | `FlowData` | Get current flow data |
-| `toJSON()` | `string` | Export flow as JSON string |
-| `validate()` | `ValidationResult` | Validate the current flow |
-| `fitView()` | `void` | Fit all nodes into view |
-| `clear()` | `void` | Remove all nodes and edges |
-| `addNode(nodeData)` | `void` | Add a node (`Partial`) |
-| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
-
-### Security: `requestInterceptor`
-
-The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior.
-
-**Guidelines for consumers:**
-
-- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor.
-- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers).
-- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely.
-
-### Node Types
-
-The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown.
-
-
-| Node Type | Description |
-| -------------------------- | ------------------------------------ |
-| `startAgentflow` | Entry point (required, always shown) |
-| `agentAgentflow` | AI agent execution |
-| `llmAgentflow` | LLM / language model call |
-| `conditionAgentflow` | Conditional branching |
-| `conditionAgentAgentflow` | Agent-level conditional branching |
-| `directReplyAgentflow` | Direct response to user |
-| `customFunctionAgentflow` | Custom JavaScript function |
-| `toolAgentflow` | Tool integration |
-| `retrieverAgentflow` | Data retrieval |
-| `stickyNoteAgentflow` | Canvas annotation (not connectable) |
-| `httpAgentflow` | HTTP request |
-| `iterationAgentflow` | Iteration / map-reduce container |
-| `executeFlowAgentflow` | Execute a sub-flow |
-
-### Design Note
-
-`` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
-
-## Development
-
-```bash
-# Install dependencies
-pnpm install
-
-# Build the package
-pnpm build
-
-# Run tests
-pnpm test
-
-# Run examples
-cd examples && pnpm install && pnpm dev
-```
-
-Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
-
-## Troubleshooting
-
-### API Connection Issues
-
-- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration.
-- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop.
-- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath.
-
-### Validation Errors
-
-- **"Flow is empty"** — Add at least one node to the canvas.
-- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette.
-- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes.
-- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle.
-
-### Theme Issues
-
-- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme.
-- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions.
-
-### Variables Not Resolving
-
-- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node.
-- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly.
-
-### Async Options Not Loading
-
-- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running.
-- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console.
-
-## Documentation
-
-- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
-- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
-- [Examples](./examples/README.md) - Usage examples and demos
-
-## Contributing
-
-This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
-
-## License
-
-Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
-
----
-
-Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
+# @flowiseai/agentflow
+
+[](https://www.npmjs.com/package/@flowiseai/agentflow)
+[](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
+
+> Embeddable React component for building and visualizing AI agent workflows
+
+## Status: Dev
+
+**Current version: `0.0.0-dev.13`**
+
+This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use.
+
+**What works today:**
+
+- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end)
+- Connection validation, flow validation, and export
+- Async option loading (models, credentials, tools)
+- Variable picker and field visibility conditions
+- AI flow generation from natural language
+- Dark/light mode theming
+- Read-only mode and custom rendering via render props
+
+## Overview
+
+`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
+
+## Features
+
+- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls
+- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow
+- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options)
+- **Credential Management** — Create and edit credentials inline from the node editor
+- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded)
+- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder
+- **AI Flow Generator** — Generate flows from natural language descriptions with model selection
+- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback
+- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables
+- **Read-Only Mode** — Disable editing for view-only embedding
+- **Custom Rendering** — Replace the default header and node palette with your own components via render props
+- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`)
+- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback
+- **Keyboard Shortcuts** — Cmd/Ctrl+S to save
+
+## Installation
+
+```bash
+pnpm add @flowiseai/agentflow
+```
+
+**Peer Dependencies:**
+
+```bash
+pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
+```
+
+## Basic Usage
+
+```tsx
+import { Agentflow } from '@flowiseai/agentflow'
+
+import '@flowiseai/agentflow/flowise.css'
+
+export default function App() {
+ return (
+
+ )
+}
+```
+
+### With Initial Flow Data and Callbacks
+
+```tsx
+import { useRef } from 'react'
+
+import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
+
+import '@flowiseai/agentflow/flowise.css'
+
+export default function App() {
+ const ref = useRef(null)
+
+ const initialFlow: FlowData = {
+ nodes: [
+ {
+ id: 'startAgentflow_0',
+ type: 'agentflowNode',
+ position: { x: 100, y: 100 },
+ data: {
+ id: 'startAgentflow_0',
+ name: 'startAgentflow',
+ label: 'Start',
+ color: '#7EE787',
+ hideInput: true,
+ outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
+ }
+ }
+ ],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 }
+ }
+
+ return (
+
+
console.log('Flow changed:', flow)}
+ onSave={(flow) => console.log('Flow saved:', flow)}
+ />
+
+ )
+}
+```
+
+### More Examples
+
+The [examples app](./examples/README.md) includes working demos for:
+
+- **Variable usage** — `{{variable}}` syntax, variable picker, available sources
+- **Async options** — Loading models, tools, and credentials from the API
+- **Status indicators** — Node execution states (running, finished, error, stopped)
+- **Field visibility** — Show/hide conditions on node inputs
+- **State management** — Dirty tracking, flow change callbacks
+- **Dark mode** — Light/dark theme toggle
+- **Custom UI** — Custom header and node palette via render props
+- **Filtered components** — Restricting available node types with presets
+- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls
+
+Run `cd examples && npm install && npm run dev` to try them locally.
+
+## Props
+
+
+| Prop | Type | Default | Description |
+| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
+| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
+| `token` | `string` | — | Authentication token for API calls |
+| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. |
+| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
+| `flowId` | `string` | — | Flow identifier (reserved for future use) |
+| `components` | `string[]` | — | Restrict which node types appear in the palette |
+| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
+| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
+| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
+| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
+| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
+| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
+| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
+| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
+| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. |
+| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
+| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
+
+### Imperative Methods (via `ref`)
+
+| Method | Return Type | Description |
+| ------------------------ | ------------------------- | ------------------------------------- |
+| `getFlow()` | `FlowData` | Get current flow data |
+| `toJSON()` | `string` | Export flow as JSON string |
+| `validate()` | `ValidationResult` | Validate the current flow |
+| `fitView()` | `void` | Fit all nodes into view |
+| `clear()` | `void` | Remove all nodes and edges |
+| `addNode(nodeData)` | `void` | Add a node (`Partial`) |
+| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
+
+### Security: `requestInterceptor`
+
+The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior.
+
+**Guidelines for consumers:**
+
+- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor.
+- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers).
+- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely.
+
+### Node Types
+
+The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown.
+
+
+| Node Type | Description |
+| -------------------------- | ------------------------------------ |
+| `startAgentflow` | Entry point (required, always shown) |
+| `agentAgentflow` | AI agent execution |
+| `llmAgentflow` | LLM / language model call |
+| `conditionAgentflow` | Conditional branching |
+| `conditionAgentAgentflow` | Agent-level conditional branching |
+| `directReplyAgentflow` | Direct response to user |
+| `customFunctionAgentflow` | Custom JavaScript function |
+| `toolAgentflow` | Tool integration |
+| `retrieverAgentflow` | Data retrieval |
+| `stickyNoteAgentflow` | Canvas annotation (not connectable) |
+| `httpAgentflow` | HTTP request |
+| `iterationAgentflow` | Iteration / map-reduce container |
+| `executeFlowAgentflow` | Execute a sub-flow |
+
+### Design Note
+
+`` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
+
+## Development
+
+```bash
+# Install dependencies
+pnpm install
+
+# Build the package
+pnpm build
+
+# Run tests
+pnpm test
+
+# Run examples
+cd examples && pnpm install && pnpm dev
+```
+
+Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
+
+## Troubleshooting
+
+### API Connection Issues
+
+- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration.
+- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop.
+- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath.
+
+### Validation Errors
+
+- **"Flow is empty"** — Add at least one node to the canvas.
+- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette.
+- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes.
+- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle.
+
+### Theme Issues
+
+- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme.
+- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions.
+
+### Variables Not Resolving
+
+- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node.
+- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly.
+
+### Async Options Not Loading
+
+- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running.
+- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console.
+
+## Documentation
+
+- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
+- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
+- [Examples](./examples/README.md) - Usage examples and demos
+
+## Contributing
+
+This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
+
+## License
+
+Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
+
+---
+
+Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
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 da766fd8bfa..e8173df164e 100644
--- a/packages/agentflow/src/index.ts
+++ b/packages/agentflow/src/index.ts
@@ -64,5 +64,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/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts
index e92bc0a39fd..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.1
+ this.version = 1.3
this.type = 'Start'
this.category = 'Agent Flows'
this.description = 'Starting point of the agentflow'
@@ -41,6 +71,12 @@ class Start_Agentflow implements INode {
name: 'formInput',
description: 'Start the workflow with form inputs',
client: ['agentflowv2']
+ },
+ {
+ label: 'Schedule Input',
+ name: 'scheduleInput',
+ description: 'Start the workflow on a recurring schedule (cron)',
+ client: ['agentflowv2']
}
],
default: 'chatInput'
@@ -126,6 +162,238 @@ 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'
+ }
+ ],
+ default: 'visualPicker',
+ 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: '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',
+ 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',
+ type: 'string',
+ placeholder: 'Run the daily report',
+ description: 'Default question/input passed to the flow when it is triggered by the scheduler.',
+ rows: 4,
+ show: {
+ 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'
+ }
+ },
{
label: 'Ephemeral Memory',
name: 'startEphemeralMemory',
@@ -214,6 +482,30 @@ class Start_Agentflow implements INode {
outputData.form = form
}
+ if (startInputType === 'scheduleInput') {
+ 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) {
outputData.ephemeralMemory = true
}
diff --git a/packages/server/.env.example b/packages/server/.env.example
index 54b1d13eecb..ad755d530c8 100644
--- a/packages/server/.env.example
+++ b/packages/server/.env.example
@@ -207,3 +207,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/__mocks__/typeorm.ts b/packages/server/__mocks__/typeorm.ts
new file mode 100644
index 00000000000..ffcdfa36558
--- /dev/null
+++ b/packages/server/__mocks__/typeorm.ts
@@ -0,0 +1,36 @@
+/**
+ * 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) => () => {}
+
+// 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,
+ PrimaryGeneratedColumn: decorator,
+ PrimaryColumn: decorator,
+ CreateDateColumn: decorator,
+ UpdateDateColumn: decorator,
+ Index: decorator,
+ ManyToOne: decorator,
+ OneToMany: decorator,
+ OneToOne: decorator,
+ JoinColumn: decorator,
+ Unique: decorator,
+ DataSource: jest.fn(),
+ In: findOperator('in'),
+ Between: findOperator('between'),
+ MoreThanOrEqual: findOperator('moreThanOrEqual'),
+ LessThanOrEqual: findOperator('lessThanOrEqual')
+}
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/package.json b/packages/server/package.json
index 6e57b093d6b..ed604723114 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -127,6 +127,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/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/Interface.ts b/packages/server/src/Interface.ts
index a46dfd4374f..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,41 @@ export interface IExecution {
workspaceId: string
}
+export type ScheduleInputMode = 'text' | 'form' | 'none'
+
+export interface IScheduleRecord {
+ id: string
+ triggerType: string
+ targetId: string
+ nodeId?: string
+ cronExpression: string
+ timezone: string
+ enabled: boolean
+ scheduleInputMode: ScheduleInputMode
+ defaultInput?: string
+ defaultForm?: 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 enum CustomMcpServerStatus {
PENDING = 'PENDING',
AUTHORIZED = 'AUTHORIZED',
diff --git a/packages/server/src/commands/base.ts b/packages/server/src/commands/base.ts
index e0187bb9d62..9c1c25d159b 100644
--- a/packages/server/src/commands/base.ts
+++ b/packages/server/src/commands/base.ts
@@ -160,7 +160,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/commands/worker.ts b/packages/server/src/commands/worker.ts
index e993c73608e..056de86bdd6 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
@@ -16,11 +17,13 @@ interface CustomListener extends QueueEventsListener {
export default class Worker extends BaseCommand {
predictionWorkerId: string
upsertionWorkerId: string
+ scheduleWorkerId: string
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({
@@ -29,7 +32,8 @@ export default class Worker extends BaseCommand {
cachePool,
appDataSource,
abortControllerPool,
- usageCacheManager
+ usageCacheManager,
+ identityManager
})
/** Prediction */
@@ -51,6 +55,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()
}
@@ -77,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) {
@@ -98,6 +119,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 ${this.scheduleWorkerId}...`)
+ 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/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts
index df115b56084..b88927b2224 100644
--- a/packages/server/src/controllers/chatflows/index.ts
+++ b/packages/server/src/controllers/chatflows/index.ts
@@ -13,6 +13,8 @@ import { getPageAndLimitParams } from '../../utils/pagination'
import { checkUsageLimit } from '../../utils/quotaUsage'
import { RateLimiterManager } from '../../utils/rateLimit'
import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData'
+import scheduleService from '../../services/schedule'
+import { ScheduleBeat } from '../../schedule/ScheduleBeat'
import { stripProtectedFields } from '../../utils/stripProtectedFields'
const checkIfChatflowIsValidForStreaming = async (req: Request, res: Response, next: NextFunction) => {
@@ -282,6 +284,106 @@ 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 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 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) {
+ 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,
@@ -293,5 +395,9 @@ export default {
updateChatflow,
getSinglePublicChatflow,
getSinglePublicChatbotConfig,
- checkIfChatflowHasChanged
+ checkIfChatflowHasChanged,
+ getScheduleStatus,
+ getScheduleTriggerLogs,
+ deleteScheduleTriggerLogs,
+ toggleScheduleEnabled
}
diff --git a/packages/server/src/database/entities/ScheduleRecord.ts b/packages/server/src/database/entities/ScheduleRecord.ts
new file mode 100644
index 00000000000..1b3310c8c0b
--- /dev/null
+++ b/packages/server/src/database/entities/ScheduleRecord.ts
@@ -0,0 +1,70 @@
+/* eslint-disable */
+import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'
+import { IScheduleRecord, ScheduleInputMode } from '../../Interface'
+
+export enum ScheduleTriggerType {
+ AGENTFLOW = 'AGENTFLOW'
+}
+
+@Entity()
+export class ScheduleRecord implements IScheduleRecord {
+ @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
+
+ @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
+
+ @Column({ nullable: true })
+ nextRunAt?: Date
+
+ /** Optional date/time after which the schedule will no longer fire */
+ @Column({ nullable: true })
+ endDate?: 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..b9aa2a7095d
--- /dev/null
+++ b/packages/server/src/database/entities/ScheduleTriggerLog.ts
@@ -0,0 +1,52 @@
+/* eslint-disable */
+import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm'
+import { IScheduleTriggerLog } from '../../Interface'
+import { ScheduleTriggerType } from './ScheduleRecord'
+
+export enum ScheduleTriggerStatus {
+ QUEUED = 'QUEUED',
+ RUNNING = 'RUNNING',
+ SUCCEEDED = 'SUCCEEDED',
+ FAILED = 'FAILED',
+ SKIPPED = 'SKIPPED'
+}
+
+@Entity()
+export class ScheduleTriggerLog implements IScheduleTriggerLog {
+ @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()
+ 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 94b2a4347a5..ce8edbefc93 100644
--- a/packages/server/src/database/entities/index.ts
+++ b/packages/server/src/database/entities/index.ts
@@ -27,6 +27,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,
@@ -59,5 +61,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..d665a5548e1
--- /dev/null
+++ b/packages/server/src/database/migrations/mariadb/1772000000000-AddScheduleEntities.ts
@@ -0,0 +1,56 @@
+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,
+ \`scheduleInputMode\` varchar(16) NOT NULL,
+ \`defaultInput\` text,
+ \`defaultForm\` 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),
+ 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 19e290999ab..3f66de53f19 100644
--- a/packages/server/src/database/migrations/mariadb/index.ts
+++ b/packages/server/src/database/migrations/mariadb/index.ts
@@ -45,6 +45,7 @@ import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermi
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
+import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables'
import { AddWorkspace1725437498242 } from '../../../enterprise/database/migrations/mariadb/1725437498242-AddWorkspace'
@@ -116,5 +117,6 @@ export const mariadbMigrations = [
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
AddCustomMcpServer1766000000000,
- AddMcpServerConfigToChatFlow1767000000000
+ AddMcpServerConfigToChatFlow1767000000000,
+ 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..d5f310c862c
--- /dev/null
+++ b/packages/server/src/database/migrations/mysql/1772000000000-AddScheduleEntities.ts
@@ -0,0 +1,56 @@
+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,
+ \`scheduleInputMode\` varchar(16) NOT NULL,
+ \`defaultInput\` text,
+ \`defaultForm\` 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),
+ 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 329da0fe6c0..00505cd321d 100644
--- a/packages/server/src/database/migrations/mysql/index.ts
+++ b/packages/server/src/database/migrations/mysql/index.ts
@@ -46,6 +46,7 @@ import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermi
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
+import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables'
import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/mysql/1720230151484-AddWorkspace'
@@ -118,5 +119,6 @@ export const mysqlMigrations = [
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
AddCustomMcpServer1766000000000,
- AddMcpServerConfigToChatFlow1767000000000
+ AddMcpServerConfigToChatFlow1767000000000,
+ 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..88115792c9a
--- /dev/null
+++ b/packages/server/src/database/migrations/postgres/1772000000000-AddScheduleEntities.ts
@@ -0,0 +1,56 @@
+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,
+ "scheduleInputMode" varchar(16) NOT NULL,
+ "defaultInput" text,
+ "defaultForm" text,
+ "lastRunAt" timestamp,
+ "nextRunAt" timestamp,
+ "endDate" 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 48679f82ae7..ad23c95c9bb 100644
--- a/packages/server/src/database/migrations/postgres/index.ts
+++ b/packages/server/src/database/migrations/postgres/index.ts
@@ -44,6 +44,7 @@ import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermi
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
+import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/postgres/1720230151482-AddAuthTables'
import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/postgres/1720230151484-AddWorkspace'
@@ -114,5 +115,6 @@ export const postgresMigrations = [
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
AddCustomMcpServer1766000000000,
- AddMcpServerConfigToChatFlow1767000000000
+ AddMcpServerConfigToChatFlow1767000000000,
+ 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..773856ef67e
--- /dev/null
+++ b/packages/server/src/database/migrations/sqlite/1772000000000-AddScheduleEntities.ts
@@ -0,0 +1,54 @@
+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,
+ "scheduleInputMode" varchar(16) NOT NULL,
+ "defaultInput" text,
+ "defaultForm" 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'))
+ );
+ `)
+
+ 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 22f42659e8e..f4378260178 100644
--- a/packages/server/src/database/migrations/sqlite/index.ts
+++ b/packages/server/src/database/migrations/sqlite/index.ts
@@ -42,6 +42,7 @@ import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermi
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
+import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/sqlite/1720230151482-AddAuthTables'
import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/sqlite/1720230151484-AddWorkspace'
@@ -110,5 +111,6 @@ export const sqliteMigrations = [
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
AddCustomMcpServer1766000000000,
- AddMcpServerConfigToChatFlow1767000000000
+ AddMcpServerConfigToChatFlow1767000000000,
+ AddScheduleEntities1772000000000
]
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 897f67af4e1..ee1d6f6dc5b 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 './schedule/ScheduleBeat'
import { RedisEventSubscriber } from './queue/RedisEventSubscriber'
import flowiseApiV1Router from './routes'
import { UsageCacheManager } from './UsageCacheManager'
@@ -143,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')
@@ -153,6 +155,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..865bc46f41c 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'
@@ -12,10 +13,11 @@ 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'
-type QUEUE_TYPE = 'prediction' | 'upsert'
+type QUEUE_TYPE = 'prediction' | 'upsert' | 'schedule'
export class QueueManager {
private static instance: QueueManager
@@ -119,6 +121,7 @@ export class QueueManager {
appDataSource,
abortControllerPool,
usageCacheManager,
+ identityManager,
serverAdapter
}: {
componentNodes: IComponentNodes
@@ -127,6 +130,7 @@ export class QueueManager {
appDataSource: DataSource
abortControllerPool: AbortControllerPool
usageCacheManager: UsageCacheManager
+ identityManager: IdentityManager
serverAdapter?: ExpressAdapter
}) {
const predictionQueueName = `${QUEUE_NAME}-prediction`
@@ -154,9 +158,24 @@ export class QueueManager {
})
this.registerQueue('upsert', upsertionQueue)
+ const scheduleQueueName = `${QUEUE_NAME}-schedule`
+ const scheduleQueue = new ScheduleQueue(scheduleQueueName, this.connection, {
+ componentNodes,
+ telemetry,
+ cachePool,
+ appDataSource,
+ usageCacheManager,
+ identityManager
+ })
+ 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/ScheduleQueue.test.ts b/packages/server/src/queue/ScheduleQueue.test.ts
new file mode 100644
index 00000000000..6f200f74bf3
--- /dev/null
+++ b/packages/server/src/queue/ScheduleQueue.test.ts
@@ -0,0 +1,293 @@
+/**
+ * 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('../schedule/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 '../schedule/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,
+ identityManager: {} 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/queue/ScheduleQueue.ts b/packages/server/src/queue/ScheduleQueue.ts
new file mode 100644
index 00000000000..581ce540389
--- /dev/null
+++ b/packages/server/src/queue/ScheduleQueue.ts
@@ -0,0 +1,135 @@
+import { RedisOptions, RepeatOptions } from 'bullmq'
+import { BaseQueue } from './BaseQueue'
+import { ScheduleRecord } from '../database/entities/ScheduleRecord'
+import { IComponentNodes } from '../Interface'
+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'
+import { executeScheduleJob } from '../schedule/ScheduleExecutor'
+import { IdentityManager } from '../IdentityManager'
+
+interface ScheduleQueueOptions {
+ appDataSource: DataSource
+ telemetry: Telemetry
+ cachePool: CachePool
+ componentNodes: IComponentNodes
+ usageCacheManager: UsageCacheManager
+ identityManager: IdentityManager
+}
+
+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 identityManager: IdentityManager
+ 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.identityManager = options.identityManager
+ 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 } = data
+
+ const ctx = {
+ appDataSource: this.appDataSource,
+ componentNodes: this.componentNodes,
+ telemetry: this.telemetry,
+ cachePool: this.cachePool,
+ usageCacheManager: this.usageCacheManager,
+ sseStreamer: this.redisPublisher,
+ identityManager: this.identityManager
+ }
+
+ 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)
+ }
+ })
+ }
+
+ /**
+ * 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.debug(`[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.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..22eaac4bfba 100644
--- a/packages/server/src/routes/chatflows/index.ts
+++ b/packages/server/src/routes/chatflows/index.ts
@@ -40,4 +40,22 @@ 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)
+router.get(
+ '/:id/schedule/trigger-logs',
+ 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/schedule/ScheduleBeat.test.ts b/packages/server/src/schedule/ScheduleBeat.test.ts
new file mode 100644
index 00000000000..e1d5d37d860
--- /dev/null
+++ b/packages/server/src/schedule/ScheduleBeat.test.ts
@@ -0,0 +1,625 @@
+/**
+ * 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('../queue/ScheduleQueue', () => ({ ScheduleQueue: class ScheduleQueue {} }))
+jest.mock('../queue/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 '../queue/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()
+ })
+
+ // ── `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 ───────────────────────────────────────────────────────────
+
+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/schedule/ScheduleBeat.ts b/packages/server/src/schedule/ScheduleBeat.ts
new file mode 100644
index 00000000000..33b1450f2b9
--- /dev/null
+++ b/packages/server/src/schedule/ScheduleBeat.ts
@@ -0,0 +1,247 @@
+/**
+ * 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 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 { 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'
+
+// ---------------------------------------------------------------------------
+
+export class ScheduleBeat {
+ private static instance: ScheduleBeat
+ 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
+ }
+
+ 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 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-
+ * 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) {
+ logger.warn(
+ '[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.'
+ )
+ }
+ await this._syncAllJobs()
+ }
+
+ /**
+ * 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 {
+ try {
+ if (action === 'delete') {
+ await this._removeJob(scheduleRecordId)
+ return
+ }
+ const appServer = getRunningExpressApp()
+ const scheduleRecord = await appServer.AppDataSource.getRepository(ScheduleRecord).findOneBy({ id: scheduleRecordId })
+
+ if (!scheduleRecord || !scheduleRecord.enabled) {
+ await this._removeJob(scheduleRecordId)
+ } else {
+ await this._upsertJob(scheduleRecord)
+ }
+ } catch (error) {
+ logger.error(`[ScheduleBeat]: onScheduleChanged error: ${error}`)
+ }
+ }
+
+ /**
+ * Stop all scheduling activity (called on graceful shutdown).
+ */
+ public async shutdown(): Promise {
+ for (const [, task] of this.cronJobs) {
+ task.stop()
+ }
+ this.cronJobs.clear()
+ }
+
+ // ─── Mode-agnostic job management ───────────────────────────────────────
+
+ /**
+ * 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)
+ }
+ }
+
+ /**
+ * 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)
+ }
+ }
+
+ /**
+ * 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
+ }
+
+ /**
+ * 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()
+ }
+
+ 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)`)
+ }
+
+ /**
+ * 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'
+
+ 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(
+ 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}`)
+ })
+ },
+ { timezone: tz }
+ )
+
+ this.cronJobs.set(record.id, task)
+ logger.debug(
+ `[ScheduleBeat]: Registered cron job for schedule ${record.id} ` +
+ `(${record.cronExpression}${hasL ? ` → ${nodeCronExpression}` : ''} ${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}`)
+ }
+ }
+
+ /**
+ * 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 ctx = {
+ appDataSource: appServer.AppDataSource,
+ componentNodes: appServer.nodesPool.componentNodes,
+ telemetry: appServer.telemetry,
+ cachePool: appServer.cachePool,
+ usageCacheManager: appServer.usageCacheManager,
+ sseStreamer: appServer.sseStreamer,
+ identityManager: appServer.identityManager
+ }
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/packages/server/src/schedule/ScheduleExecutor.test.ts b/packages/server/src/schedule/ScheduleExecutor.test.ts
new file mode 100644
index 00000000000..abb620be2c3
--- /dev/null
+++ b/packages/server/src/schedule/ScheduleExecutor.test.ts
@@ -0,0 +1,651 @@
+/**
+ * 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: {
+ isScheduleInputValid: 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',
+ () => ({
+ 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 {} }))
+jest.mock('../IdentityManager', () => ({ IdentityManager: class IdentityManager {} }))
+jest.mock('../utils/quotaUsage', () => ({
+ checkPredictions: jest.fn(),
+ updatePredictionsUsage: jest.fn()
+}))
+
+// ─── 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',
+ scheduleInputMode: 'text' as const,
+ 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 mockWorkspaceFindOneBy: jest.Mock
+let mockOrgFindOneBy: jest.Mock
+let mockAppDataSource: { getRepository: jest.Mock }
+let mockCtx: any
+
+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().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,
+ componentNodes: {},
+ telemetry: {},
+ cachePool: {},
+ usageCacheManager: {},
+ sseStreamer: {},
+ identityManager: { getProductIdFromSubscription: jest.fn().mockResolvedValue('prod-1') }
+ }
+ ;(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)
+})
+
+// ─── 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.isScheduleInputValid 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.isScheduleInputValid 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.isScheduleInputValid 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.isScheduleInputValid 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,
+ 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 () => {
+ 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: 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', () => {
+ 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/schedule/ScheduleExecutor.ts b/packages/server/src/schedule/ScheduleExecutor.ts
new file mode 100644
index 00000000000..5f166475b40
--- /dev/null
+++ b/packages/server/src/schedule/ScheduleExecutor.ts
@@ -0,0 +1,242 @@
+/**
+ * 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 { ChatType, 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 { 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 ─────────────────────────────────────────────────────────────────────
+
+/**
+ * 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
+ identityManager: IdentityManager
+}
+
+/**
+ * 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 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({
+ 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, identityManager } = 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`)
+ 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 = { 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({
+ componentNodes,
+ incomingInput,
+ chatflow,
+ chatId,
+ appDataSource,
+ telemetry,
+ cachePool,
+ usageCacheManager,
+ sseStreamer,
+ baseURL: process.env.APP_URL ?? '',
+ isInternal: true,
+ chatType: ChatType.SCHEDULED,
+ orgId,
+ workspaceId,
+ 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 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
+ } 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/services/chatflows/index.test.ts b/packages/server/src/services/chatflows/index.test.ts
new file mode 100644
index 00000000000..7d64b13f8c6
--- /dev/null
+++ b/packages/server/src/services/chatflows/index.test.ts
@@ -0,0 +1,595 @@
+/**
+ * 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('../../schedule/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 '../../schedule/ScheduleBeat'
+import { containsBase64File } from '../../utils/fileRepository'
+import { EnumChatflowType } from '../../database/entities/ChatFlow'
+import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord'
+
+const mockContainsBase64File = containsBase64File 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',
+ scheduleInputMode: 'text',
+ 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 }))
+ })
+
+ // ── 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() }))
+
+ 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/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts
index 3821d741316..531d838e54e 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, QueryRunner } 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'
@@ -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 from '../../services/schedule'
+import { ScheduleBeat } from '../../schedule/ScheduleBeat'
export const enum ChatflowErrorMessage {
INVALID_CHATFLOW_TYPE = 'Invalid Chatflow Type',
@@ -111,7 +114,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 })
@@ -127,6 +130,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.deleteScheduleForTarget(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)
@@ -354,6 +365,54 @@ 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 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({
+ triggerType: ScheduleTriggerType.AGENTFLOW,
+ targetId: dbResponse.id,
+ nodeId: startNode?.id,
+ cronExpression: resolvedCron.cronExpression || '',
+ timezone: scheduleTimezone,
+ enabled: enabled,
+ scheduleInputMode,
+ defaultInput: scheduleInputMode === 'text' ? scheduleDefaultInput : '',
+ defaultForm: scheduleFormDefaults,
+ workspaceId,
+ endDate: scheduleEndDate
+ })
+ if (enabled) {
+ // 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(
@@ -419,6 +478,62 @@ const updateChatflow = async (
await _checkAndUpdateDocumentStoreUsage(newDbChatflow, 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
+ 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 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({
+ 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
+ scheduleInputMode,
+ defaultInput: scheduleInputMode === 'text' ? scheduleDefaultInput : '',
+ defaultForm: scheduleFormDefaults,
+ workspaceId,
+ endDate: scheduleEndDate
+ })
+ if (record.enabled) {
+ // Notify the beat to sync the (enabled) schedule
+ await ScheduleBeat.getInstance().onScheduleChanged(record.id, 'upsert')
+ } else {
+ // 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 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')
+ }
+ }
+ }
+
return dbResponse
}
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
new file mode 100644
index 00000000000..552f381e86e
--- /dev/null
+++ b/packages/server/src/services/schedule/index.test.ts
@@ -0,0 +1,708 @@
+/**
+ * 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(),
+ findAndCount: jest.fn(),
+ create: jest.fn(),
+ save: jest.fn(),
+ delete: jest.fn(),
+ update: jest.fn(),
+ merge: 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('../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() }
+}))
+
+// ─── 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',
+ scheduleInputMode: 'text' as const,
+ 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',
+ scheduleInputMode: 'text' as const,
+ 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.merge.mockReturnValue(saved)
+ 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()
+ })
+})
+
+// ─── 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)
+ })
+})
+
+// ─── 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
new file mode 100644
index 00000000000..dee0a74cf4e
--- /dev/null
+++ b/packages/server/src/services/schedule/index.ts
@@ -0,0 +1,463 @@
+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'
+import { InternalFlowiseError } from '../../errors/internalFlowiseError'
+import { getErrorMessage } from '../../errors/utils'
+import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
+import logger from '../../utils/logger'
+import executionsService from '../executions'
+import {
+ validateCronExpression,
+ computeNextRunAt,
+ isScheduleInputValid,
+ resolveScheduleCron,
+ validateVisualPickerFields,
+ buildCronFromVisualPicker,
+ canScheduleEnable
+} from './utils'
+import { ICommonObject } from 'flowise-components'
+import { ScheduleInputMode } from '../../Interface'
+
+export {
+ validateCronExpression,
+ computeNextRunAt,
+ validateVisualPickerFields,
+ buildCronFromVisualPicker,
+ resolveScheduleCron,
+ isScheduleInputValid,
+ canScheduleEnable
+} from './utils'
+export type { VisualPickerInput } from './utils'
+
+export interface CreateScheduleInput {
+ triggerType: ScheduleTriggerType
+ targetId: string
+ nodeId?: string
+ cronExpression: string
+ timezone?: string
+ enabled?: boolean
+ scheduleInputMode: ScheduleInputMode
+ defaultInput?: string
+ defaultForm?: string
+ endDate?: Date
+ workspaceId: string
+}
+
+export interface UpdateScheduleInput {
+ cronExpression?: string
+ timezone?: string
+ enabled?: boolean
+ scheduleInputMode?: ScheduleInputMode
+ defaultInput?: string
+ defaultForm?: 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.
+ */
+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
+
+const createOrUpdateSchedule = async (input: CreateScheduleInput): Promise => {
+ try {
+ const appServer = getRunningExpressApp()
+ const repo = appServer.AppDataSource.getRepository(ScheduleRecord)
+
+ const validation = validateCronExpression(input.cronExpression, input.timezone ?? FALLBACK_TIMEZONE)
+ const cronExpression = validation.valid ? input.cronExpression : FALLBACK_CRON_EXPRESSION
+ const timezone = validation.valid ? input.timezone ?? FALLBACK_TIMEZONE : FALLBACK_TIMEZONE
+
+ // Upsert: find existing record for this target + triggerType
+ const existing = await repo.findOne({
+ where: {
+ targetId: input.targetId,
+ triggerType: input.triggerType,
+ workspaceId: input.workspaceId
+ }
+ })
+
+ if (existing) {
+ const updateSchedule = new ScheduleRecord()
+ const bodySchedule: ICommonObject = {
+ cronExpression,
+ timezone
+ }
+ if (input.enabled !== undefined) bodySchedule.enabled = input.enabled
+ if (input.scheduleInputMode !== undefined) bodySchedule.scheduleInputMode = input.scheduleInputMode
+ if (input.defaultInput !== undefined) bodySchedule.defaultInput = input.defaultInput
+ if (input.defaultForm !== undefined) bodySchedule.defaultForm = input.defaultForm
+ if (input.nodeId !== undefined) bodySchedule.nodeId = input.nodeId
+ bodySchedule.endDate = input.endDate ?? null
+ bodySchedule.nextRunAt = computeNextRunAt(cronExpression, timezone) ?? null
+
+ // NOTE: Use assign + merge to update `endDate` and `nextRunAt` even if they are null
+ Object.assign(updateSchedule, bodySchedule)
+ const merged = repo.merge(existing, updateSchedule)
+ const saved = await repo.save(merged)
+ logger.debug(`[ScheduleService]: Updated schedule ${saved.id} for ${input.triggerType}:${input.targetId}`)
+ return saved
+ }
+
+ const record = repo.create({
+ triggerType: input.triggerType,
+ targetId: input.targetId,
+ nodeId: input.nodeId,
+ cronExpression: cronExpression,
+ timezone: timezone,
+ enabled: input.enabled !== undefined ? input.enabled : validation.valid, // default to enabled if valid, disabled if invalid
+ scheduleInputMode: input.scheduleInputMode,
+ defaultInput: input.defaultInput,
+ defaultForm: input.defaultForm,
+ endDate: input.endDate,
+ nextRunAt: computeNextRunAt(cronExpression, timezone) ?? undefined,
+ 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)}`
+ )
+ }
+}
+
+/**
+ * Deletes the schedule record for a given target and trigger type.
+ * NOTE: The log should be retained for historical/audit purposes, even if the schedule is deleted.
+ */
+const deleteScheduleForTarget = 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
+ await repo.delete(record.id)
+ logger.debug(`[ScheduleService]: Deleted schedule for ${triggerType}:${targetId}`)
+ return record
+ } catch (error) {
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Error: scheduleService.deleteScheduleForTarget - ${getErrorMessage(error)}`
+ )
+ }
+}
+
+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 },
+ order: { createdDate: 'ASC' },
+ skip,
+ take
+ })
+ } catch (error) {
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Error: scheduleService.getEnabledSchedulesBatch - ${getErrorMessage(error)}`
+ )
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Cron field helpers (used by computeNextRunAt)
+// ---------------------------------------------------------------------------
+
+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]: 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' }
+ }
+ }
+
+ // Validate input presence according to the chosen schedule input mode.
+ // 'text' requires a non-empty default input; 'form' requires at least one form field; 'none' is always valid.
+ const mode = (inputs.scheduleInputMode as ScheduleInputMode) ?? record?.scheduleInputMode
+ if (!mode) {
+ return { record, canEnable: false, reason: 'Schedule Input Mode is required' }
+ }
+ const isInputValidResult = isScheduleInputValid(mode, inputs.scheduleDefaultInput, inputs.scheduleFormInputTypes)
+ if (!isInputValidResult) {
+ const reason =
+ mode === 'form'
+ ? 'At least one form field must be defined to enable schedule'
+ : 'Default input is required to enable schedule'
+ return { record, canEnable: false, reason }
+ }
+
+ 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)}`
+ )
+ }
+}
+
+// ─── 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)}`)
+ }
+}
+
+// ─── Trigger-log queries (for the Schedule History UI) ───────────────────────
+
+export interface GetTriggerLogsFilter {
+ /** Optional status filter (single value or array) */
+ status?: ScheduleTriggerStatus | ScheduleTriggerStatus[]
+ /** 1-based page */
+ page?: number
+ /** Page size; defaults to 20, clamped to [1, 100] */
+ limit?: number
+}
+
+/**
+ * Returns a paginated list of trigger-log rows for the schedule of a given target
+ * (chatflow/agentflow), scoped to the workspace. Newest first.
+ */
+const getTriggerLogs = async (
+ targetId: string,
+ workspaceId: string,
+ filter: GetTriggerLogsFilter = {}
+): Promise<{ data: ScheduleTriggerLog[]; total: number; page: number; limit: number }> => {
+ 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)}`
+ )
+ }
+}
+
+/**
+ * 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 {
+ validateCronExpression,
+ validateVisualPickerFields,
+ buildCronFromVisualPicker,
+ resolveScheduleCron,
+ createOrUpdateSchedule,
+ deleteScheduleForTarget,
+ getEnabledSchedulesBatch,
+ updateScheduleAfterRun,
+ computeNextRunAt,
+ createTriggerLog,
+ updateTriggerLog,
+ getScheduleStatus,
+ toggleScheduleEnabled,
+ getTriggerLogs,
+ deleteTriggerLogs,
+ isScheduleInputValid,
+ canScheduleEnable
+}
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..567255a792d
--- /dev/null
+++ b/packages/server/src/services/schedule/utils.test.ts
@@ -0,0 +1,974 @@
+import {
+ validateCronExpression,
+ computeNextRunAt,
+ validateVisualPickerFields,
+ buildCronFromVisualPicker,
+ resolveScheduleCron,
+ isScheduleInputValid,
+ canScheduleEnable,
+ expandCronLForNodeCron,
+ cronDomMatchesNow,
+ 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', () => {
+ const result = validateCronExpression('0 * * * * *')
+ expect(result.valid).toBe(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/)
+ })
+ })
+
+ 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)
+ })
+ })
+
+ 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 ─────────────────────────────────────────────────────────
+
+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)
+ })
+
+ // ── 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)
+ })
+
+ // ── `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 ───────────────────────────────────────────────
+
+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 })
+ })
+
+ 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
+ })
+ })
+})
+
+// ─── 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)
+ })
+ })
+})
+
+// ─── isScheduleInputValid ─────────────────────────────────────────────────────
+
+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)
+ })
+ })
+
+ 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)
+ })
+ })
+
+ describe("mode='none'", () => {
+ it('always returns true regardless of other inputs', () => {
+ expect(isScheduleInputValid('none', undefined, undefined)).toBe(true)
+ expect(isScheduleInputValid('none', '', [])).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 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
+ })
+ ).toBe(false)
+ })
+
+ it('returns false when end date is in the past', () => {
+ expect(
+ canScheduleEnable({
+ scheduleCronExpression: '* * * * *',
+ scheduleInputMode: 'text',
+ scheduleDefaultInput: 'hello',
+ scheduleEndDate: pastDate
+ })
+ ).toBe(false)
+ })
+
+ it('returns false when default input is missing', () => {
+ expect(
+ canScheduleEnable({
+ scheduleCronExpression: '* * * * *',
+ scheduleInputMode: 'text',
+ scheduleDefaultInput: undefined
+ })
+ ).toBe(false)
+ })
+
+ it('returns false when default input is rich-text empty', () => {
+ expect(
+ canScheduleEnable({
+ scheduleCronExpression: '* * * * *',
+ scheduleInputMode: 'text',
+ scheduleDefaultInput: '
'
+ })
+ ).toBe(false)
+ })
+
+ it('returns true when all conditions are valid (no end date)', () => {
+ expect(
+ canScheduleEnable({
+ scheduleCronExpression: '0 9 * * 1-5',
+ scheduleInputMode: 'text',
+ 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',
+ scheduleInputMode: 'text',
+ 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',
+ 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)
+ })
+ })
+})
+
+// ─── 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
new file mode 100644
index 00000000000..f8ef2b6d0be
--- /dev/null
+++ b/packages/server/src/services/schedule/utils.ts
@@ -0,0 +1,618 @@
+/**
+ * Pure utility functions for schedule management.
+ * 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)
+
+/**
+ * Validates a cron expression and returns parsed info.
+ * Uses a lightweight regex-based check without external dependencies.
+ *
+ * Supports extended 6-field cron: second minute hour day month weekday
+ */
+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' }
+ }
+
+ 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.
+ // 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)
+ 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
+ // 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], i === domIndex)) {
+ 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 }
+}
+
+// ---------------------------------------------------------------------------
+// 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
+}
+
+/**
+ * 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
+ 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, year: 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')
+ year = get('year')
+ 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
+ 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) &&
+ _matchDomField(parsed.domField, dom, lastDay) &&
+ _matchCronField(parsed.monthField, month, 1) &&
+ dowMatches
+ )
+}
+
+/**
+ * Computes the next Date after `after` (defaults to now) when the cron expression will fire.
+ *
+ * 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.
+ */
+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())
+
+ // 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
+ })
+
+ 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
+}
+
+// ─── 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 {
+ 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) {
+ 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 or L)` }
+ }
+ }
+ }
+
+ 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 }
+}
+
+/**
+ * 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 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: 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 mode = inputs.scheduleInputMode
+ if (!mode) return false
+ const isInputValid = isScheduleInputValid(mode, inputs.scheduleDefaultInput, inputs.scheduleFormInputTypes)
+ return cronResult.valid && isEndDateValid && isInputValid
+}
diff --git a/packages/server/src/services/validation/index.ts b/packages/server/src/services/validation/index.ts
index c7cb7fba603..17e76b6351f 100644
--- a/packages/server/src/services/validation/index.ts
+++ b/packages/server/src/services/validation/index.ts
@@ -4,6 +4,7 @@ import { getErrorMessage } from '../../errors/utils'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import { ChatFlow } from '../../database/entities/ChatFlow'
import { INodeParams } from 'flowise-components'
+import { resolveScheduleCron } from '../schedule'
import { IComponentNodes, IReactFlowEdge, IReactFlowNode, IReactFlowObject } from '../../Interface'
export interface IValidationResult {
@@ -236,6 +237,14 @@ export const validateFlowData = (
}
}
+ // 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/api/chatflows.js b/packages/ui/src/api/chatflows.js
index a5d4f323ac5..ea42c67225c 100644
--- a/packages/ui/src/api/chatflows.js
+++ b/packages/ui/src/api/chatflows.js
@@ -22,6 +22,14 @@ 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 })
+
+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,
@@ -33,5 +41,9 @@ export default {
getIsChatflowStreaming,
getAllowChatflowUploads,
getHasChatflowChanged,
- generateAgentflow
+ generateAgentflow,
+ getScheduleStatus,
+ toggleScheduleEnabled,
+ getScheduleTriggerLogs,
+ deleteScheduleTriggerLogs
}
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/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx
index e89b383ef44..b6840f866e8 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/ui-component/picker/DatePicker.jsx b/packages/ui/src/ui-component/picker/DatePicker.jsx
new file mode 100644
index 00000000000..3d9ba8f6b37
--- /dev/null
+++ b/packages/ui/src/ui-component/picker/DatePicker.jsx
@@ -0,0 +1,69 @@
+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) => {
+ 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 (
+
+ {
+ if (!disabled) e.currentTarget.showPicker?.()
+ }
+ }}
+ sx={{
+ '& .MuiInputBase-root': {
+ cursor: disabled ? 'default' : 'pointer',
+ '& fieldset': {
+ borderColor: theme.palette.grey[900] + 25
+ }
+ },
+ '& input': {
+ cursor: disabled ? 'default' : 'pointer'
+ }
+ }}
+ />
+
+ )
+}
+
+DatePicker.propTypes = {
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string
+}
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..e481f1ed012
--- /dev/null
+++ b/packages/ui/src/ui-component/picker/MonthDaysPicker.jsx
@@ -0,0 +1,112 @@
+import { useState, useEffect } from 'react'
+import PropTypes from 'prop-types'
+import { Box, Chip, Tooltip } from '@mui/material'
+import { useTheme } from '@mui/material/styles'
+
+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()
+
+ 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 []
+ }
+
+ // 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(sortDays(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 = sortDays(next)
+ setSelected(next)
+ onChange(next.join(','))
+ }
+
+ const renderChip = (valueToken, label) => {
+ const isSelected = selected.includes(valueToken)
+ const isLastDay = valueToken === LAST_DAY_TOKEN
+ const chip = (
+ 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]
+ }
+ }}
+ />
+ )
+ if (isLastDay) {
+ return (
+
+ {chip}
+
+ )
+ }
+ return chip
+ }
+
+ return (
+
+ {DAYS_OF_MONTH.map((day) => renderChip(day, day === LAST_DAY_TOKEN ? 'Last Day' : day))}
+
+ )
+}
+
+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..15a9de3c0c8
--- /dev/null
+++ b/packages/ui/src/ui-component/picker/TimePicker.jsx
@@ -0,0 +1,60 @@
+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(() => {
+ setTimeValue(value || '')
+ }, [value])
+
+ const handleChange = (e) => {
+ const newValue = e.target.value
+ setTimeValue(newValue)
+ onChange(newValue)
+ }
+
+ return (
+
+ {
+ if (!disabled) e.currentTarget.showPicker?.()
+ }
+ }}
+ sx={{
+ '& .MuiInputBase-root': {
+ cursor: disabled ? 'default' : 'pointer',
+ '& fieldset': {
+ borderColor: theme.palette.grey[900] + 25
+ }
+ },
+ '& input': {
+ cursor: disabled ? 'default' : 'pointer'
+ }
+ }}
+ />
+
+ )
+}
+
+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..d9f464828a4 100644
--- a/packages/ui/src/utils/genericHelper.js
+++ b/packages/ui/src/utils/genericHelper.js
@@ -137,7 +137,11 @@ 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',
+ 'datePicker'
]
// Inputs
@@ -1277,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]
@@ -1287,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)
}
}
@@ -1304,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/AgentFlowNode.jsx b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx
index 3205612eefc..4d1a932ffe4 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,
+ IconListDetails
} 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/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/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/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx
index 85dccf45134..ea455f41c2e 100644
--- a/packages/ui/src/views/canvas/CanvasHeader.jsx
+++ b/packages/ui/src/views/canvas/CanvasHeader.jsx
@@ -1,14 +1,23 @@
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 { 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'
@@ -32,6 +41,83 @@ 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, { 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
+ }
+ }
+})
+
+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: isDark ? '#4a3e1f' : '#f5e6b8'
+ }
+}))
+
// ==============================|| CANVAS HEADER ||============================== //
const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, handleDeleteFlow, handleLoadFlow }) => {
@@ -66,7 +152,26 @@ 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 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
+ 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 +353,49 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow,
}
}, [chatflow, title, chatflowConfigurationDialogOpen])
+ useEffect(() => {
+ if (chatflow?.id && isScheduleFlow) {
+ setScheduleStatusLoaded(false)
+ 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 || '')
+ setScheduleStatusLoaded(true)
+ }
+ // 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 (
<>
@@ -387,7 +535,41 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow,
)}
-
+
+ {chatflow?.id && isAgentflowV2 && isScheduleFlow && scheduleStatusLoaded && (
+
+
+ {!scheduleCanEnable && !scheduleEnabled ? (
+ <>
+
+
+ >
+ ) : (
+ handleToggleSchedule(e.target.checked)}
+ isDark={isDark}
+ />
+ )}
+
+
+ )}
{chatflow?.id && (
>
)}
+ {inputParam.type === 'timePicker' && (
+ handleDataChange({ inputParam, newValue })}
+ />
+ )}
+ {inputParam.type === 'weekDaysPicker' && (
+ handleDataChange({ inputParam, newValue })}
+ />
+ )}
+ {inputParam.type === 'monthDaysPicker' && (
+ handleDataChange({ inputParam, newValue })}
+ />
+ )}
+ {inputParam.type === 'datePicker' && (
+ handleDataChange({ inputParam, newValue })}
+ />
+ )}
{inputParam.type === 'array' && }
{/* CUSTOM INPUT LOGIC */}
{inputParam.type.includes('conditionFunction') && (
diff --git a/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx
new file mode 100644
index 00000000000..a314bc21d04
--- /dev/null
+++ b/packages/ui/src/views/schedule/ScheduleHistoryDrawer.jsx
@@ -0,0 +1,789 @@
+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,
+ Button,
+ Checkbox,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Drawer,
+ FormControlLabel,
+ IconButton,
+ 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, 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
+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') : '—')
+
+// 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)
+ 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, 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)
+ const [executionData, setExecutionData] = useState(null)
+ const [executionMetadata, setExecutionMetadata] = useState(null)
+
+ // 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, limit, statusApi, logsApi])
+
+ // initial + page/limit change
+ useEffect(() => {
+ if (open) fetchAll()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [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])
+ 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.')
+ })
+ }
+ }
+
+ // ─── 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) => (
+ closeSnackbar(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) => (
+ closeSnackbar(key)}>
+
+
+ )
+ }
+ })
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ // ─── 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]
+
+ return (
+ <>
+
+ {/* Left-edge drag handle: click-and-drag to resize */}
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleMouseDown()
+ }
+ }}
+ >
+
+
+ {/* Header */}
+
+
+
+
+
+ Schedule History
+
+
+
+
+
+
+
+
+
+
+ {cronHuman}
+
+
+
+
+
+
+ Last run
+
+
+ {lastLog ? relTime(lastLog.scheduledAt) : '—'}
+
+
+
+
+ Next run
+
+ {(() => {
+ if (!enabled || !nextRunAt) {
+ return —
+ }
+ const { text, overdue } = fmtNextRun(nextRunAt)
+ const tz = record?.timezone || 'UTC'
+ const exactInTz = fmtDateInTz(nextRunAt, tz)
+ const exactLocal = fmtDate(nextRunAt)
+ return (
+
+
+
+ {text}
+
+
+ {exactInTz} ({tz})
+
+
+
+ )
+ })()}
+
+
+
+
+
+
+
+
+
+ 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 && }
+
+
+ {!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'
+ const isSelected = selectedIds.includes(row.id)
+ return (
+
+ e.stopPropagation()}>
+ toggleRowSelected(row.id)}
+ inputProps={{ 'aria-label': `Select row ${row.id}` }}
+ />
+
+ handleRowClick(row) : undefined}>
+
+
+ handleRowClick(row) : undefined}>
+
+ {relTime(row.scheduledAt)}
+
+
+ handleRowClick(row) : undefined}>
+ {fmtDuration(row.elapsedTimeMs)}
+
+ handleRowClick(row) : undefined}
+ sx={{
+ maxWidth: 240,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: 'error.main'
+ }}
+ >
+ {row.error ? (
+
+ {row.error}
+
+ ) : (
+ —
+ )}
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {/* Footer: items-per-page + page selector + total count (mirrors Agent Executions pattern) */}
+ {total > 0 && (
+
+
+
+ )}
+
+
+ {/* Nested execution drawer — executionData is already the parsed array */}
+ {executionOpen && executionData && (
+ setExecutionOpen(false)}
+ onRefresh={() => execApi.request(executionMetadata?.id)}
+ isPublic={false}
+ />
+ )}
+
+ {/* 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.
+
+
+
+ setDeleteDialogOpen(false)} disabled={deleting}>
+ Cancel
+
+
+ {deleting ? 'Deleting…' : 'Delete'}
+
+
+
+
+ {/* 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
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 677518624d6..f85f78c5f0e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1111,6 +1111,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
@@ -16062,6 +16065,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'}
@@ -41127,6 +41134,8 @@ snapshots:
node-cleanup@2.1.2: {}
+ node-cron@4.2.1: {}
+
node-domexception@1.0.0: {}
node-ensure@0.0.0: {}