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 - -[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow) -[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](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 + +[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](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) => ( + + ) + } + }) + setSelectedIds([]) + setDeleteDialogOpen(false) + fetchAll() + } catch (e) { + enqueueSnackbar({ + message: e?.response?.data?.message || e?.message || 'Failed to delete logs', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } finally { + setDeleting(false) + } + } + + // ─── Header derived values ─────────────────────────────────────────────── + + const record = statusData?.record + 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 */} + + {/* 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. + + + + + + + + + {/* 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: {}