diff --git a/.env.example b/.env.example index 7bccd6955..37b5a8b88 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ E2E_DEFAULT_MODEL= # If you don't have PostgreSQL running locally, start it with: pnpm docker:pg POSTGRES_URL=postgres://your_username:your_password@localhost:5432/your_database_name +# Secret used to authorize workflow scheduler dispatches (set any random string) +WORKFLOW_SCHEDULER_SECRET= + # Secret for Better Auth (generate with: npx @better-auth/cli@latest secret) BETTER_AUTH_SECRET=**** diff --git a/README.md b/README.md index c63d66ea3..a0b771952 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Built with Vercel AI SDK and Next.js, combining the best features of leading AI - [πŸ” OAuth Sign-In Setup](#-oauth-sign-in-setup) - [πŸ•΅πŸΏ Adding openAI like providers](#-adding-openai-like-providers) - [πŸ§ͺ E2E Testing Guide](#-e2e-testing-guide) + - [πŸ•’ Workflow Scheduler](#-workflow-scheduler) - [πŸ’‘ Tips](#-tips) - [πŸ’¬ Temporary Chat Windows](#-temporary-chat-windows) - [πŸ—ΊοΈ Roadmap](#️-roadmap) @@ -306,6 +307,10 @@ BETTER_AUTH_URL= # If you don't have PostgreSQL running locally, start it with: pnpm docker:pg POSTGRES_URL=postgres://your_username:your_password@localhost:5432/your_database_name +# === Workflow Scheduler === +# Shared secret required by /api/workflow/schedules/dispatch +WORKFLOW_SCHEDULER_SECRET=your_random_string + # (Optional) # === Tools === # Exa AI for web search and content extraction (optional, but recommended for @web and research features) @@ -395,6 +400,10 @@ Step-by-step setup guides for running and configuring better-chatbot. #### [πŸ§ͺ E2E Testing Guide](./docs/tips-guides/e2e-testing-guide.md) - Comprehensive end-to-end testing with Playwright including multi-user scenarios, agent visibility testing, and CI/CD integration + +#### [πŸ•’ Workflow Scheduler](./docs/tips-guides/workflow-scheduler.md) + +- Configure Scheduler nodes, set the shared secret, and wire cron jobs to `/api/workflow/schedules/dispatch`
## πŸ’‘ Tips diff --git a/docs/tips-guides/workflow-scheduler.md b/docs/tips-guides/workflow-scheduler.md new file mode 100644 index 000000000..48c59b992 --- /dev/null +++ b/docs/tips-guides/workflow-scheduler.md @@ -0,0 +1,129 @@ +# Workflow Scheduler + +Trigger published workflows on a recurring cadence by combining the Scheduler node with the `/api/workflow/schedules/dispatch` endpoint. This guide explains what you need to configure, how schedule execution works, and a few examples for wiring it up to cron jobs or external task runners. + +## Requirements + +- **Workflow Scheduler Secret** – set `WORKFLOW_SCHEDULER_SECRET` in `.env` (any random string). Every dispatch request must present this secret. +- **Published workflow** – only published workflows that contain at least one Scheduler node are eligible to run. Draft workflows are ignored. +- **Cron or job runner** – you must call the dispatch endpoint on a cadence (e.g., Vercel Cron, GitHub Actions, Cloudflare Workers, Kubernetes CronJob, or a local `cron` entry). + +## Configuring Scheduler Nodes + +1. Add a **Scheduler** node to your workflow and fill in: + - `cron` – standard 5-part cron expression (validated via `cron-parser`). + - `timezone` – Olson TZ string (defaults to the workflow owner's timezone). + - `enabled` – scheduler rows are skipped when disabled. + - `payload` – optional JSON object passed as the workflow input when this schedule runs. +2. Publish the workflow. Saving/publishing will upsert the node's schedule in the `workflow_schedule` table and compute the next run time. + +When a schedule fires, the workflow executor runs with: + +- The node's `payload` merged into the execution `query`. +- Optional workflow context containing the owner's id, name, and email (when available). +- History disabled and a 5-minute timeout to keep scheduler runs short-lived. + +## Dispatch Endpoint + +``` +POST /api/workflow/schedules/dispatch +``` + +### Authentication + +Send the scheduler secret by using one of the supported headers: + +- `Authorization: Bearer ` +- `x-workflow-scheduler-secret: ` +- `x-cron-secret: ` + +The request is rejected with `401 Unauthorized` when the secret is missing or mismatched. A `500` error indicates the secret is not configured on the server. + +### Request Body + +`Content-Type: application/json` with the following optional fields: + +- `limit` – maximum number of schedules to process (default `5`, min `1`, max `25`). +- `dryRun` – when `true`, schedules are locked then immediately released (useful for monitoring or smoke tests). + +### Response Shape + +```json +{ + "ok": true, + "result": { + "scanned": 3, + "locked": 2, + "success": 2, + "failed": 0, + "skipped": 1, + "errors": [] + } +} +``` + +- `scanned` – due schedules inspected during this dispatch. +- `locked` – schedules successfully locked by this worker. +- `success` / `failed` – execution outcome counts. +- `skipped` – schedules skipped because they were already locked, disabled, or the request was a dry run. +- `errors` – array of `{ scheduleId, message }` entries for failed runs. + +Locks automatically expire after five minutes to protect against stuck workers. Each successful run recomputes the next run time using the stored cron expression. + +## Example Cron Invocations + +### Local cron (every minute) + +```bash +* * * * * curl -s -X POST \ + -H "x-workflow-scheduler-secret: $WORKFLOW_SCHEDULER_SECRET" \ + https://your-domain.com/api/workflow/schedules/dispatch > /dev/null +``` + +### Vercel Cron Job + +1. Set `WORKFLOW_SCHEDULER_SECRET` in your Vercel project settings. +2. Add a cron entry in `vercel.json`: + +```json +{ + "crons": [ + { + "path": "/api/workflow/schedules/dispatch", + "schedule": "*/5 * * * *", + "headers": { + "x-workflow-scheduler-secret": "@WORKFLOW_SCHEDULER_SECRET" + } + } + ] +} +``` + +Vercel automatically injects the secret value referenced by the `@` syntax. + +### GitHub Actions + +```yaml +name: Workflow Scheduler +on: + schedule: + - cron: "*/10 * * * *" +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Trigger schedules + run: | + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.WORKFLOW_SCHEDULER_SECRET }}" \ + https://your-domain.com/api/workflow/schedules/dispatch +``` + +## Troubleshooting + +- **`Unauthorized`** – confirm the header value matches `WORKFLOW_SCHEDULER_SECRET` on the server. +- **`ok: true` but `skipped` > 0** – another worker already locked those schedules, or the request used `dryRun: true`. +- **Workflows never run** – ensure the workflow is published and the Scheduler node is enabled with a valid cron + timezone. +- **Need visibility** – temporarily run with `dryRun: true` to gather lock stats without executing flows. + +With these steps in place, your Scheduler nodes will run reliably at whatever cadence you define. diff --git a/messages/en.json b/messages/en.json index 1ac73a758..f5a530fce 100644 --- a/messages/en.json +++ b/messages/en.json @@ -146,7 +146,9 @@ "code": "Execute custom code scripts with access to previous node data.\n\nRun JavaScript, Python, or other languages within your workflow (coming soon).", "http": "Fetch data from external APIs and web services via HTTP requests.\n\nIntegrate with REST APIs, webhooks, and third-party services.", "template": "Create dynamic documents by combining text with data from previous nodes.\n\nGenerate emails, reports, or formatted content using variable substitution.", - "condition": "Add conditional logic to branch your workflow based on data evaluation.\n\nCreate if-else logic to handle different scenarios and data conditions." + "condition": "Add conditional logic to branch your workflow based on data evaluation.\n\nCreate if-else logic to handle different scenarios and data conditions.", + "reply-in-thread": "Create a new chat thread for the current user with scripted messages.\n\nUse '/' mentions to inject data from previous nodes before saving the conversation.", + "scheduler": "Trigger workflows on a recurring schedule defined by cron expressions.\n\nUse timezone-aware schedules to automate recurring tasks without manual input." }, "structuredOutputSwitchConfirm": "You currently have structured output enabled.\n What would you like to do?", "structuredOutputSwitchConfirmOk": "Edit Structured Output", @@ -154,7 +156,22 @@ "noTools": "No published workflows available.\nCreate workflows to build custom tools.", "arrangeNodes": "Auto Layout", "nodesArranged": "Layout applied successfully", - "visibilityUpdated": "Visibility updated successfully" + "visibilityUpdated": "Visibility updated successfully", + "schedulerCronExpression": "Cron expression", + "schedulerCronHelper": "Use standard 5-field cron syntax. Examples: '0 * * * *' or '0 9 * * MON'.", + "schedulerCronDocs": "Open crontab.guru", + "schedulerTimezone": "Timezone", + "schedulerTimezoneHelper": "Use an IANA timezone like 'UTC' or 'America/New_York'.", + "schedulerEnabled": "Enabled", + "schedulerEnabledDescription": "Paused schedules will not run.", + "schedulerPayload": "Payload", + "schedulerPayloadDescription": "JSON payload supplied as workflow input when the schedule triggers.", + "schedulerInvalidJson": "Please enter valid JSON.", + "schedulerStackCronLabel": "Cron", + "schedulerStackTimezoneLabel": "TZ", + "schedulerStackStatusLabel": "Status", + "schedulerStatusActive": "Active", + "schedulerStatusPaused": "Paused" }, "Auth": { "SignIn": { diff --git a/package.json b/package.json index 918b8586c..fce9dc805 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "consola": "^3.4.2", + "cron-parser": "^5.4.0", "date-fns": "^4.1.0", "deepmerge": "^4.3.1", "dotenv": "^16.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d75e314f3..d6744d4dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: consola: specifier: ^3.4.2 version: 3.4.2 + cron-parser: + specifier: ^5.4.0 + version: 5.4.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -3531,6 +3534,10 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@5.4.0: + resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==} + engines: {node: '>=18'} + cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -4933,6 +4940,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -9934,6 +9945,10 @@ snapshots: crelt@1.0.6: {} + cron-parser@5.4.0: + dependencies: + luxon: 3.7.2 + cross-env@7.0.3: dependencies: cross-spawn: 7.0.6 @@ -11571,6 +11586,8 @@ snapshots: dependencies: react: 19.2.0 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 9f6e4e21c..6ab72a76a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -51,6 +51,7 @@ import { nanoBananaTool, openaiImageTool } from "lib/ai/tools/image"; import { ImageToolName } from "lib/ai/tools"; import { buildCsvIngestionPreviewParts } from "@/lib/ai/ingest/csv-ingest"; import { serverFileStorage } from "lib/file-storage"; +import type { WorkflowExecutionContext } from "lib/ai/workflow/workflow.interface"; const logger = globalLogger.withDefaults({ message: colorize("blackBright", `Chat API: `), @@ -65,6 +66,13 @@ export async function POST(request: Request) { if (!session?.user.id) { return new Response("Unauthorized", { status: 401 }); } + const workflowContext: WorkflowExecutionContext = { + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }, + }; const { id, message, @@ -225,6 +233,7 @@ export async function POST(request: Request) { loadWorkFlowTools({ mentions, dataStream, + context: workflowContext, }), ) .orElse({}); diff --git a/src/app/api/chat/shared.chat.ts b/src/app/api/chat/shared.chat.ts index e794efb9f..a611f895b 100644 --- a/src/app/api/chat/shared.chat.ts +++ b/src/app/api/chat/shared.chat.ts @@ -37,7 +37,11 @@ import { VercelAIWorkflowToolTag, } from "app-types/workflow"; import { createWorkflowExecutor } from "lib/ai/workflow/executor/workflow-executor"; -import { NodeKind } from "lib/ai/workflow/workflow.interface"; +import { + NodeKind, + WorkflowExecutionContext, + withWorkflowContext, +} from "lib/ai/workflow/workflow.interface"; import { mcpClientsManager } from "lib/ai/mcp/mcp-manager"; import { APP_DEFAULT_TOOL_KIT } from "lib/ai/tools/tool-kit"; import { AppDefaultToolkit } from "lib/ai/tools"; @@ -225,12 +229,14 @@ export const workflowToVercelAITool = ({ schema, dataStream, name, + context, }: { id: string; name: string; description?: string; schema: ObjectJsonSchema7; dataStream: UIMessageStreamWriter; + context?: WorkflowExecutionContext; }): VercelAIWorkflowTool => { const toolName = name .replace(/[^a-zA-Z0-9\s]/g, "") @@ -316,9 +322,14 @@ export const workflowToVercelAITool = ({ output: toolResult, }); }); + const runtimeQuery = withWorkflowContext( + (query ?? undefined) as Record | undefined, + context, + ); + return executor.run( { - query: query ?? ({} as any), + query: runtimeQuery, }, { disableHistory: true, @@ -376,12 +387,14 @@ export const workflowToVercelAITools = ( schema: ObjectJsonSchema7; }[], dataStream: UIMessageStreamWriter, + context?: WorkflowExecutionContext, ) => { return workflows .map((v) => workflowToVercelAITool({ ...v, dataStream, + context, }), ) .reduce( @@ -409,6 +422,7 @@ export const loadMcpTools = (opt?: { export const loadWorkFlowTools = (opt: { mentions?: ChatMention[]; dataStream: UIMessageStreamWriter; + context?: WorkflowExecutionContext; }) => safe(() => opt?.mentions?.length @@ -422,7 +436,7 @@ export const loadWorkFlowTools = (opt: { ) : [], ) - .map((tools) => workflowToVercelAITools(tools, opt.dataStream)) + .map((tools) => workflowToVercelAITools(tools, opt.dataStream, opt.context)) .orElse({} as Record); export const loadAppDefaultTools = (opt?: { diff --git a/src/app/api/workflow/[id]/execute/route.ts b/src/app/api/workflow/[id]/execute/route.ts index c54362b3f..f9d7bf151 100644 --- a/src/app/api/workflow/[id]/execute/route.ts +++ b/src/app/api/workflow/[id]/execute/route.ts @@ -2,6 +2,10 @@ import { getSession } from "auth/server"; import { createWorkflowExecutor } from "lib/ai/workflow/executor/workflow-executor"; import { workflowRepository } from "lib/db/repository"; import { encodeWorkflowEvent } from "lib/ai/workflow/shared.workflow"; +import { + withWorkflowContext, + WorkflowExecutionContext, +} from "lib/ai/workflow/workflow.interface"; import logger from "logger"; import { colorize } from "consola/utils"; import { safeJSONParse, toAny } from "lib/utils"; @@ -77,9 +81,24 @@ export async function POST( }); // Start the workflow + const workflowContext: WorkflowExecutionContext | undefined = session.user + ?.id + ? { + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }, + } + : undefined; + const runtimeQuery = withWorkflowContext( + query as Record | undefined, + workflowContext, + ); + app .run( - { query }, + { query: runtimeQuery }, { disableHistory: true, timeout: 1000 * 60 * 5, diff --git a/src/app/api/workflow/schedules/dispatch/route.ts b/src/app/api/workflow/schedules/dispatch/route.ts new file mode 100644 index 000000000..c27f5ce8c --- /dev/null +++ b/src/app/api/workflow/schedules/dispatch/route.ts @@ -0,0 +1,59 @@ +import { dispatchWorkflowSchedules } from "lib/ai/workflow/workflow-scheduler"; + +const HEADER_SECRET_KEYS = [ + "authorization", + "x-workflow-scheduler-secret", + "x-cron-secret", +]; + +function extractSecretToken(request: Request): string | null { + for (const headerKey of HEADER_SECRET_KEYS) { + const headerValue = request.headers.get(headerKey); + if (!headerValue) continue; + if (headerKey === "authorization") { + const [scheme, token] = headerValue.split(" "); + if (scheme?.toLowerCase() === "bearer" && token) { + return token; + } + } else if (headerValue.trim().length) { + return headerValue.trim(); + } + } + return null; +} + +export async function POST(request: Request) { + const secret = process.env.WORKFLOW_SCHEDULER_SECRET; + if (!secret) { + return new Response("Scheduler secret is not configured", { + status: 500, + }); + } + + const providedSecret = extractSecretToken(request); + if (providedSecret !== secret) { + return new Response("Unauthorized", { status: 401 }); + } + + let body: { limit?: number; dryRun?: boolean } = {}; + if (request.headers.get("content-type")?.includes("application/json")) { + try { + body = (await request.json()) as typeof body; + } catch { + body = {}; + } + } + + const limit = + typeof body.limit === "number" && Number.isFinite(body.limit) + ? Math.max(1, Math.min(25, Math.floor(body.limit))) + : undefined; + const dryRun = typeof body.dryRun === "boolean" ? body.dryRun : false; + + const result = await dispatchWorkflowSchedules({ limit, dryRun }); + + return Response.json({ + ok: true, + result, + }); +} diff --git a/src/components/workflow/default-node.tsx b/src/components/workflow/default-node.tsx index fcd9248f6..82b01d90c 100644 --- a/src/components/workflow/default-node.tsx +++ b/src/components/workflow/default-node.tsx @@ -25,6 +25,8 @@ import { createAppendNode } from "./create-append-node"; import { ToolNodeStack } from "./node-config/tool-node-config"; import { Markdown } from "../markdown"; import { HttpNodeDataStack } from "./node-config/http-node-config"; +import { ReplyInThreadNodeStack } from "./node-config/reply-in-thread-node-config"; +import { SchedulerNodeStack } from "./node-config/scheduler-node-config"; type Props = NodeProps; @@ -106,7 +108,9 @@ export const DefaultNode = memo(function DefaultNode({ )} >
- {![NodeKind.Note, NodeKind.Input].includes(data.kind) && ( + {![NodeKind.Note, NodeKind.Input, NodeKind.Scheduler].includes( + data.kind, + ) && (
{data.name}
- {![NodeKind.Note, NodeKind.Output, NodeKind.Condition].includes( - data.kind, - ) && ( + {![ + NodeKind.Note, + NodeKind.Output, + NodeKind.Condition, + NodeKind.Scheduler, + ].includes(data.kind) && ( update()} @@ -197,6 +204,12 @@ export const DefaultNode = memo(function DefaultNode({ )} {data.kind === NodeKind.Tool && } {data.kind === NodeKind.Http && } + {data.kind === NodeKind.ReplyInThread && ( + + )} + {data.kind === NodeKind.Scheduler && ( + + )} {data.description && (
diff --git a/src/components/workflow/node-config/reply-in-thread-node-config.tsx b/src/components/workflow/node-config/reply-in-thread-node-config.tsx new file mode 100644 index 000000000..6f87da3bc --- /dev/null +++ b/src/components/workflow/node-config/reply-in-thread-node-config.tsx @@ -0,0 +1,185 @@ +import { memo, useCallback } from "react"; +import { Edge, useEdges, useNodes, useReactFlow } from "@xyflow/react"; +import { MessageCirclePlusIcon, TrashIcon } from "lucide-react"; +import { Button } from "ui/button"; +import { Label } from "ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; +import { useWorkflowStore } from "@/app/store/workflow.store"; +import { OutputSchemaMentionInput } from "../output-schema-mention-input"; +import { + ReplyInThreadNodeData, + UINode, +} from "lib/ai/workflow/workflow.interface"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; +import { VariableIcon } from "lucide-react"; + +export const ReplyInThreadNodeConfig = memo(function ReplyInThreadNodeConfig({ + data, +}: { + data: ReplyInThreadNodeData; +}) { + const t = useTranslations(); + const { updateNodeData } = useReactFlow(); + const nodes = useNodes() as UINode[]; + const edges = useEdges() as Edge[]; + const editable = useWorkflowStore((state) => { + return ( + state.processIds.length === 0 && + state.hasEditAccess && + !state.workflow?.isPublished + ); + }); + + const updateMessage = useCallback( + ( + index: number, + message: Partial, + ) => { + updateNodeData(data.id, (node) => { + const prev = node.data as ReplyInThreadNodeData; + return { + messages: prev.messages.map((m, i) => { + if (i !== index) return m; + return { ...m, ...message }; + }), + }; + }); + }, + [data.id, updateNodeData], + ); + + const removeMessage = useCallback( + (index: number) => { + updateNodeData(data.id, (node) => { + const prev = node.data as ReplyInThreadNodeData; + return { + messages: prev.messages.filter((_, i) => i !== index), + }; + }); + }, + [data.id, updateNodeData], + ); + + const addMessage = useCallback(() => { + updateNodeData(data.id, (node) => { + const prev = node.data as ReplyInThreadNodeData; + return { + messages: [...prev.messages, { role: "user" }], + }; + }); + }, [data.id, updateNodeData]); + + const messageHelper = useMemo(() => { + return ( + t("Workflow.messagesDescription") || + "Use '/' to reference data from previous nodes." + ); + }, [t]); + + return ( +
+
+ + + updateNodeData(data.id, { + title, + }) + } + /> +
+ +
+
+ + + + + + + {messageHelper} + + +
+
+ {data.messages.map((message, index) => ( +
+
+ + +
+ updateMessage(index, { content })} + /> +
+ ))} +
+ +
+
+ ); +}); +ReplyInThreadNodeConfig.displayName = "ReplyInThreadNodeConfig"; + +export const ReplyInThreadNodeStack = memo(function ReplyInThreadNodeStack({ + data, +}: { + data: ReplyInThreadNodeData; +}) { + return ( +
+
+ Messages + + {data.messages.length} + +
+
+ ); +}); +ReplyInThreadNodeStack.displayName = "ReplyInThreadNodeStack"; diff --git a/src/components/workflow/node-config/scheduler-node-config.tsx b/src/components/workflow/node-config/scheduler-node-config.tsx new file mode 100644 index 000000000..a4d510a91 --- /dev/null +++ b/src/components/workflow/node-config/scheduler-node-config.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { memo, useCallback, useMemo, useState } from "react"; +import { SchedulerNodeData } from "lib/ai/workflow/workflow.interface"; +import { useReactFlow } from "@xyflow/react"; +import { Label } from "ui/label"; +import { Input } from "ui/input"; +import { Textarea } from "ui/textarea"; +import { Switch } from "ui/switch"; +import { useTranslations } from "next-intl"; +import { InfoIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; +import { cn } from "lib/utils"; + +const COMMON_TIMEZONES = [ + "UTC", + "America/New_York", + "America/Los_Angeles", + "Europe/London", + "Europe/Berlin", + "Asia/Singapore", + "Asia/Tokyo", + "Australia/Sydney", +]; + +export const SchedulerNodeConfig = memo(function SchedulerNodeConfig({ + data, +}: { + data: SchedulerNodeData; +}) { + const t = useTranslations(); + const { updateNodeData } = useReactFlow(); + const [payloadText, setPayloadText] = useState(() => + JSON.stringify(data.payload ?? {}, null, 2), + ); + const [payloadError, setPayloadError] = useState(null); + + const cronHelper = useMemo(() => { + return ( + t("Workflow.schedulerCronHelper") || + "Use standard 5-field cron syntax. Examples: '0 * * * *' or '0 9 * * MON'." + ); + }, [t]); + + const timezoneHelper = useMemo(() => { + return ( + t("Workflow.schedulerTimezoneHelper") || + "Use an IANA timezone like 'UTC' or 'America/New_York'." + ); + }, [t]); + + const payloadHelper = useMemo(() => { + return ( + t("Workflow.schedulerPayloadDescription") || + "JSON payload supplied as workflow input when the schedule triggers." + ); + }, [t]); + + const handleCronChange = useCallback( + (cron: string) => { + updateNodeData(data.id, { cron }); + }, + [data.id, updateNodeData], + ); + + const handleTimezoneChange = useCallback( + (timezone: string) => { + updateNodeData(data.id, { timezone }); + }, + [data.id, updateNodeData], + ); + + const handleEnabledChange = useCallback( + (enabled: boolean) => { + updateNodeData(data.id, { enabled }); + }, + [data.id, updateNodeData], + ); + + const handlePayloadChange = useCallback( + (value: string) => { + setPayloadText(value); + if (!value.trim()) { + updateNodeData(data.id, { payload: {} }); + setPayloadError(null); + return; + } + + try { + const parsed = JSON.parse(value); + setPayloadError(null); + updateNodeData(data.id, { payload: parsed }); + } catch { + setPayloadError(t("Workflow.schedulerInvalidJson")); + } + }, + [data.id, t, updateNodeData], + ); + + return ( +
+
+
+ + + + + + + {cronHelper} + + +
+ handleCronChange(event.target.value)} + /> + + {t("Workflow.schedulerCronDocs") ?? "Open crontab.guru"} + +
+ +
+
+ + + + + + + {timezoneHelper} + + +
+ handleTimezoneChange(event.target.value)} + /> + + {COMMON_TIMEZONES.map((zone) => ( + +
+ +
+
+ +

+ {t("Workflow.schedulerEnabledDescription") ?? + "Paused schedules will not run."} +

+
+ +
+ +
+
+ + + + + + + {payloadHelper} + + +
+