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 ( + + + Thread Title + + updateNodeData(data.id, { + title, + }) + } + /> + + + + + Messages + + + + + + {messageHelper} + + + + + {data.messages.map((message, index) => ( + + + + updateMessage(index, { + role: value as ReplyInThreadNodeData["messages"][number]["role"], + }) + } + > + + {message.role.toUpperCase()} + + + USER + ASSISTANT + SYSTEM + + + removeMessage(index)} + disabled={!editable} + > + + + + updateMessage(index, { content })} + /> + + ))} + + + {" "} + {t("Workflow.addMessage")} + + + + ); +}); +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 ( + + + + + {t("Workflow.schedulerCronExpression")}* + + + + + + + {cronHelper} + + + + handleCronChange(event.target.value)} + /> + + {t("Workflow.schedulerCronDocs") ?? "Open crontab.guru"} + + + + + + {t("Workflow.schedulerTimezone")} + + + + + + {timezoneHelper} + + + + handleTimezoneChange(event.target.value)} + /> + + {COMMON_TIMEZONES.map((zone) => ( + + ))} + + + + + + {t("Workflow.schedulerEnabled")} + + {t("Workflow.schedulerEnabledDescription") ?? + "Paused schedules will not run."} + + + + + + + + {t("Workflow.schedulerPayload")} + + + + + + {payloadHelper} + + + + handlePayloadChange(event.target.value)} + spellCheck={false} + /> + {payloadError && ( + {payloadError} + )} + + + ); +}); +SchedulerNodeConfig.displayName = "SchedulerNodeConfig"; + +export const SchedulerNodeStack = memo(function SchedulerNodeStack({ + data, +}: { + data: SchedulerNodeData; +}) { + const t = useTranslations(); + const enabled = data.enabled ?? true; + return ( + + + + {t("Workflow.schedulerStackCronLabel") || "Cron"} + + + {data.cron || "-"} + + + + + {t("Workflow.schedulerStackTimezoneLabel") || "TZ"} + + + {data.timezone || "UTC"} + + + + + {t("Workflow.schedulerStackStatusLabel") || "Status"} + + + {enabled + ? t("Workflow.schedulerStatusActive") || "Active" + : t("Workflow.schedulerStatusPaused") || "Paused"} + + + + ); +}); +SchedulerNodeStack.displayName = "SchedulerNodeStack"; diff --git a/src/components/workflow/node-icon.tsx b/src/components/workflow/node-icon.tsx index 429258b35..b64cd8fea 100644 --- a/src/components/workflow/node-icon.tsx +++ b/src/components/workflow/node-icon.tsx @@ -13,6 +13,8 @@ import { TerminalIcon, TextIcon, WrenchIcon, + MessageCircleReply, + CalendarClock, } from "lucide-react"; import { useMemo } from "react"; @@ -39,6 +41,10 @@ export function NodeIcon({ return HardDriveUpload; case NodeKind.Template: return TextIcon; + case NodeKind.ReplyInThread: + return MessageCircleReply; + case NodeKind.Scheduler: + return CalendarClock; case NodeKind.Code: return TerminalIcon; default: @@ -63,9 +69,13 @@ export function NodeIcon({ ? "bg-rose-500" : type === NodeKind.Template ? "bg-purple-500" - : type === NodeKind.Condition - ? "bg-amber-500" - : "bg-card", + : type === NodeKind.ReplyInThread + ? "bg-emerald-500" + : type === NodeKind.Condition + ? "bg-amber-500" + : type === NodeKind.Scheduler + ? "bg-cyan-500" + : "bg-card", "p-1 rounded", className, )} diff --git a/src/components/workflow/selected-node-config-tab.tsx b/src/components/workflow/selected-node-config-tab.tsx index 723750104..9a450f6f3 100644 --- a/src/components/workflow/selected-node-config-tab.tsx +++ b/src/components/workflow/selected-node-config-tab.tsx @@ -22,6 +22,8 @@ import { ToolNodeDataConfig } from "./node-config/tool-node-config"; import { HttpNodeConfig } from "./node-config/http-node-config"; import { TemplateNodeConfig } from "./node-config/template-node-config"; import { useTranslations } from "next-intl"; +import { ReplyInThreadNodeConfig } from "./node-config/reply-in-thread-node-config"; +import { SchedulerNodeConfig } from "./node-config/scheduler-node-config"; export function SelectedNodeConfigTab({ node }: { node: UINode }) { const t = useTranslations(); @@ -99,6 +101,10 @@ export function SelectedNodeConfigTab({ node }: { node: UINode }) { ) : node.data.kind === NodeKind.Template ? ( + ) : node.data.kind === NodeKind.ReplyInThread ? ( + + ) : node.data.kind === NodeKind.Scheduler ? ( + ) : node.data.kind === NodeKind.Note ? ( - {![NodeKind.Output, NodeKind.Note].includes(node.data.kind) && ( + {![NodeKind.Output, NodeKind.Note, NodeKind.Scheduler].includes( + node.data.kind, + ) && ( <> diff --git a/src/lib/ai/workflow/create-ui-node.ts b/src/lib/ai/workflow/create-ui-node.ts index fb729b64e..241b147ab 100644 --- a/src/lib/ai/workflow/create-ui-node.ts +++ b/src/lib/ai/workflow/create-ui-node.ts @@ -103,6 +103,25 @@ export function createUINode( content: [], }, }; + } else if (node.data.kind === NodeKind.ReplyInThread) { + node.data.outputSchema = structuredClone( + defaultReplyInThreadNodeOutputSchema, + ); + node.data.messages = [ + { + role: "user", + }, + ]; + node.data.title = { + type: "doc", + content: [], + }; + } else if (node.data.kind === NodeKind.Scheduler) { + node.data.outputSchema = structuredClone(defaultSchedulerNodeOutputSchema); + node.data.cron = "0 * * * *"; + node.data.timezone = "UTC"; + node.data.enabled = true; + node.data.payload = {}; } return node; @@ -128,3 +147,42 @@ export const defaultTemplateNodeOutputSchema: ObjectJsonSchema7 = { }, }, }; + +export const defaultReplyInThreadNodeOutputSchema: ObjectJsonSchema7 = { + type: "object", + properties: { + threadId: { + type: "string", + }, + title: { + type: "string", + }, + messageIds: { + type: "array", + items: { + type: "string", + }, + }, + messageCount: { + type: "number", + }, + }, +}; + +export const defaultSchedulerNodeOutputSchema: ObjectJsonSchema7 = { + type: "object", + properties: { + scheduleId: { + type: "string", + }, + lastRunAt: { + type: "string", + }, + nextRunAt: { + type: "string", + }, + status: { + type: "string", + }, + }, +}; diff --git a/src/lib/ai/workflow/executor/node-executor.ts b/src/lib/ai/workflow/executor/node-executor.ts index 4e8e80549..8928ad283 100644 --- a/src/lib/ai/workflow/executor/node-executor.ts +++ b/src/lib/ai/workflow/executor/node-executor.ts @@ -8,7 +8,11 @@ import { ToolNodeData, HttpNodeData, TemplateNodeData, + ReplyInThreadNodeData, OutputSchemaSourceKey, + WORKFLOW_CONTEXT_KEY, + WorkflowExecutionContext, + SchedulerNodeData, } from "../workflow.interface"; import { WorkflowRuntimeState } from "./graph-store"; import { @@ -23,7 +27,7 @@ import { convertTiptapJsonToText, } from "../shared.workflow"; import { jsonSchemaToZod } from "lib/json-schema-to-zod"; -import { toAny } from "lib/utils"; +import { generateUUID, toAny } from "lib/utils"; import { AppError } from "lib/errors"; import { DefaultToolName } from "lib/ai/tools"; import { @@ -31,6 +35,7 @@ import { exaContentsToolForWorkflow, } from "lib/ai/tools/web/web-search"; import { mcpClientsManager } from "lib/ai/mcp/mcp-manager"; +import { chatRepository } from "lib/db/repository"; /** * Interface for node executor functions. @@ -52,6 +57,16 @@ export type NodeExecutor = (input: { output?: any; }; +function getWorkflowContext( + state: WorkflowRuntimeState, +): WorkflowExecutionContext | undefined { + const context = state.query?.[WORKFLOW_CONTEXT_KEY]; + if (!context || typeof context !== "object") { + return undefined; + } + return context as WorkflowExecutionContext; +} + /** * Input Node Executor * Entry point of the workflow - passes the initial query data to subsequent nodes @@ -500,3 +515,101 @@ export const templateNodeExecutor: NodeExecutor = ({ }, }; }; + +export const replyInThreadNodeExecutor: NodeExecutor< + ReplyInThreadNodeData +> = async ({ node, state }) => { + const context = getWorkflowContext(state); + const userId = context?.user?.id; + + if (!userId) { + throw new Error( + "Reply-in-thread node requires an authenticated user context", + ); + } + + if (!node.title) { + throw new Error("Reply-in-thread node must define a title"); + } + + if (!node.messages?.length) { + throw new Error("Reply-in-thread node must include messages to save"); + } + + const resolvedTitle = convertTiptapJsonToText({ + json: node.title, + getOutput: state.getOutput, + }).trim(); + + if (!resolvedTitle) { + throw new Error("Reply-in-thread node resolved title is empty"); + } + + const resolvedMessages = node.messages.map((message, index) => { + if (!message.content) { + throw new Error(`Message #${index + 1} is missing content`); + } + + const aiMessage = convertTiptapJsonToAiMessage({ + role: message.role, + getOutput: state.getOutput, + json: message.content, + }); + + const hasTextContent = aiMessage.parts.some((part) => { + return part.type === "text" && part.text.trim().length > 0; + }); + + if (!hasTextContent) { + throw new Error(`Message #${index + 1} resolved to empty content`); + } + + return { + id: generateUUID(), + role: aiMessage.role, + parts: aiMessage.parts, + }; + }); + + const thread = await chatRepository.insertThread({ + id: generateUUID(), + title: resolvedTitle, + userId, + }); + + const savedMessages = await chatRepository.insertMessages( + resolvedMessages.map((message) => ({ + ...message, + threadId: thread.id, + })), + ); + + return { + input: { + title: resolvedTitle, + messages: resolvedMessages, + }, + output: { + thread: { + id: thread.id, + title: thread.title, + createdAt: thread.createdAt, + }, + messageIds: savedMessages.map((message) => message.id), + messageCount: savedMessages.length, + }, + }; +}; + +export const schedulerNodeExecutor: NodeExecutor = ({ + node, +}) => { + return { + output: { + cron: node.cron, + timezone: node.timezone, + enabled: node.enabled ?? true, + payload: node.payload, + }, + }; +}; diff --git a/src/lib/ai/workflow/executor/workflow-executor.ts b/src/lib/ai/workflow/executor/workflow-executor.ts index c2b24cb7e..efa9fc5c5 100644 --- a/src/lib/ai/workflow/executor/workflow-executor.ts +++ b/src/lib/ai/workflow/executor/workflow-executor.ts @@ -10,6 +10,8 @@ import { toolNodeExecutor, httpNodeExecutor, templateNodeExecutor, + replyInThreadNodeExecutor, + schedulerNodeExecutor, } from "./node-executor"; import { toAny } from "lib/utils"; import { addEdgeBranchLabel } from "./add-edge-branch-label"; @@ -39,6 +41,10 @@ function getExecutorByKind(kind: NodeKind): NodeExecutor { return httpNodeExecutor; case NodeKind.Template: return templateNodeExecutor; + case NodeKind.ReplyInThread: + return replyInThreadNodeExecutor; + case NodeKind.Scheduler: + return schedulerNodeExecutor; case "NOOP" as any: return () => { return { diff --git a/src/lib/ai/workflow/node-validate.ts b/src/lib/ai/workflow/node-validate.ts index 65d11f1dd..8da9bf7e7 100644 --- a/src/lib/ai/workflow/node-validate.ts +++ b/src/lib/ai/workflow/node-validate.ts @@ -1,5 +1,6 @@ import { Edge } from "@xyflow/react"; import { JSONSchema7 } from "json-schema"; +import CronExpressionParser from "cron-parser"; import { ConditionNodeData, OutputNodeData, @@ -11,6 +12,8 @@ import { ToolNodeData, HttpNodeData, TemplateNodeData, + ReplyInThreadNodeData, + SchedulerNodeData, } from "lib/ai/workflow/workflow.interface"; import { cleanVariableName } from "lib/utils"; import { safe } from "ts-safe"; @@ -109,6 +112,10 @@ export const nodeValidate: NodeValidate = ({ return httpNodeValidate({ node, nodes, edges }); case NodeKind.Template: return templateNodeValidate({ node, nodes, edges }); + case NodeKind.ReplyInThread: + return replyInThreadNodeValidate({ node, nodes, edges }); + case NodeKind.Scheduler: + return schedulerNodeValidate({ node, nodes, edges }); } }; @@ -270,3 +277,55 @@ export const templateNodeValidate: NodeValidate = ({ // Template content can be undefined/empty - that's valid // The actual content validation is handled by the TipTap editor }; + +export const replyInThreadNodeValidate: NodeValidate = ({ + node, +}) => { + if (!node.title) { + throw new Error("Reply-in-thread node requires a title"); + } + + if (!node.messages?.length) { + throw new Error("Reply-in-thread node must include at least one message"); + } + + node.messages.forEach((message, index) => { + if (!message.role) { + throw new Error(`Message #${index + 1} must have a role`); + } + if (!message.content) { + throw new Error(`Message #${index + 1} requires content`); + } + }); +}; + +export const schedulerNodeValidate: NodeValidate = ({ + node, +}) => { + if (!node.cron || !node.cron.trim()) { + throw new Error("Scheduler node requires a cron expression"); + } + + try { + CronExpressionParser.parse(node.cron, { + currentDate: new Date(), + tz: node.timezone || "UTC", + }); + } catch { + throw new Error("Invalid cron expression"); + } + + if (node.timezone) { + try { + new Intl.DateTimeFormat("en-US", { + timeZone: node.timezone, + }).format(new Date()); + } catch { + throw new Error("Invalid timezone identifier"); + } + } + + if (node.payload !== undefined && typeof node.payload !== "object") { + throw new Error("Scheduler payload must be a JSON object"); + } +}; diff --git a/src/lib/ai/workflow/scheduler-utils.ts b/src/lib/ai/workflow/scheduler-utils.ts new file mode 100644 index 000000000..0de36fa9e --- /dev/null +++ b/src/lib/ai/workflow/scheduler-utils.ts @@ -0,0 +1,17 @@ +import CronExpressionParser from "cron-parser"; + +export function computeNextRunDate( + cron: string, + timezone?: string, + fromDate?: Date, +): Date | null { + try { + const interval = CronExpressionParser.parse(cron, { + currentDate: fromDate ?? new Date(), + tz: timezone || "UTC", + }); + return interval.next().toDate(); + } catch { + return null; + } +} diff --git a/src/lib/ai/workflow/workflow-scheduler.ts b/src/lib/ai/workflow/workflow-scheduler.ts new file mode 100644 index 000000000..77709598d --- /dev/null +++ b/src/lib/ai/workflow/workflow-scheduler.ts @@ -0,0 +1,233 @@ +import { createWorkflowExecutor } from "./executor/workflow-executor"; +import { withWorkflowContext } from "./workflow.interface"; +import { computeNextRunDate } from "./scheduler-utils"; +import { workflowRepository, userRepository } from "lib/db/repository"; +import { pgDb } from "lib/db/pg/db.pg"; +import { WorkflowScheduleTable } from "lib/db/pg/schema.pg"; +import { and, asc, eq, isNull, isNotNull, lte, lt, or } from "drizzle-orm"; +import { generateUUID } from "lib/utils"; +import logger from "logger"; +import { colorize } from "consola/utils"; + +const DEFAULT_LIMIT = 5; +const LOCK_TIMEOUT_MS = 5 * 60 * 1000; + +type WorkflowScheduleRow = typeof WorkflowScheduleTable.$inferSelect; + +export type WorkflowSchedulerDispatchOptions = { + limit?: number; + dryRun?: boolean; + workerId?: string; +}; + +export type WorkflowSchedulerDispatchResult = { + scanned: number; + locked: number; + success: number; + failed: number; + skipped: number; + errors: { scheduleId: string; message: string }[]; +}; + +export async function dispatchWorkflowSchedules( + options: WorkflowSchedulerDispatchOptions = {}, +): Promise { + const { limit = DEFAULT_LIMIT, dryRun = false } = options; + const workerId = options.workerId ?? generateUUID(); + + const now = new Date(); + const dueSchedules = await pgDb + .select() + .from(WorkflowScheduleTable) + .where( + and( + eq(WorkflowScheduleTable.enabled, true), + isNotNull(WorkflowScheduleTable.nextRunAt), + lte(WorkflowScheduleTable.nextRunAt, now), + ), + ) + .orderBy(asc(WorkflowScheduleTable.nextRunAt)) + .limit(limit); + + const summary: WorkflowSchedulerDispatchResult = { + scanned: dueSchedules.length, + locked: 0, + success: 0, + failed: 0, + skipped: 0, + errors: [], + }; + + for (const schedule of dueSchedules) { + const locked = await lockSchedule(schedule.id, workerId, now); + if (!locked) { + summary.skipped += 1; + continue; + } + + summary.locked += 1; + + if (dryRun) { + await releaseScheduleLock(schedule.id); + summary.skipped += 1; + continue; + } + + const runResult = await executeSchedule(locked).catch((error: any) => ({ + ok: false, + error, + })); + + if (runResult.ok) { + summary.success += 1; + } else { + summary.failed += 1; + summary.errors.push({ + scheduleId: schedule.id, + message: runResult.error?.message || "Unknown error", + }); + } + } + + return summary; +} + +async function lockSchedule(scheduleId: string, workerId: string, now: Date) { + const lockExpiry = new Date(now.getTime() - LOCK_TIMEOUT_MS); + const [row] = await pgDb + .update(WorkflowScheduleTable) + .set({ + lockedAt: new Date(), + lockedBy: workerId, + }) + .where( + and( + eq(WorkflowScheduleTable.id, scheduleId), + eq(WorkflowScheduleTable.enabled, true), + lte(WorkflowScheduleTable.nextRunAt, now), + or( + isNull(WorkflowScheduleTable.lockedAt), + lt(WorkflowScheduleTable.lockedAt, lockExpiry), + eq(WorkflowScheduleTable.lockedBy, workerId), + ), + ), + ) + .returning(); + + return row ?? null; +} + +async function releaseScheduleLock(scheduleId: string) { + await pgDb + .update(WorkflowScheduleTable) + .set({ lockedAt: null, lockedBy: null }) + .where(eq(WorkflowScheduleTable.id, scheduleId)); +} + +async function executeSchedule(schedule: WorkflowScheduleRow) { + const wfLogger = logger.withDefaults({ + message: colorize( + "cyan", + `Scheduler[${schedule.workflowId}] node(${schedule.workflowNodeId})`, + ), + }); + + const workflow = await workflowRepository.selectStructureById( + schedule.workflowId, + ); + if (!workflow) { + await finalizeScheduleRun(schedule, { + errorMessage: "Workflow not found", + recordRun: false, + }); + return { ok: false, error: new Error("Workflow not found") }; + } + + if (!workflow.isPublished) { + await finalizeScheduleRun(schedule, { + errorMessage: "Workflow is not published", + skipNextComputation: false, + recordRun: false, + }); + return { ok: false, error: new Error("Workflow is not published") }; + } + + const payloadValue = schedule.payload as + | Record + | null + | undefined; + const schedulePayload = payloadValue ?? {}; + + const owner = workflow.userId + ? await userRepository.getUserById(workflow.userId) + : null; + + const workflowContext = owner + ? { + user: { + id: owner.id, + email: owner.email, + name: owner.name, + }, + } + : undefined; + + const runtimeQuery = withWorkflowContext(schedulePayload, workflowContext); + + try { + const executor = createWorkflowExecutor({ + nodes: workflow.nodes, + edges: workflow.edges, + logger: wfLogger, + }); + + const result = await executor.run( + { query: runtimeQuery }, + { + disableHistory: true, + timeout: 1000 * 60 * 5, + }, + ); + + if (!result.isOk) { + throw result.error || new Error("Workflow execution failed"); + } + + await finalizeScheduleRun(schedule, {}); + return { ok: true }; + } catch (error) { + await finalizeScheduleRun(schedule, { + errorMessage: error instanceof Error ? error.message : String(error), + }); + return { ok: false, error }; + } +} + +async function finalizeScheduleRun( + schedule: WorkflowScheduleRow, + options: { + errorMessage?: string | null; + skipNextComputation?: boolean; + recordRun?: boolean; + }, +) { + const runCompletedAt = new Date(); + const nextRunAt = + schedule.enabled && !options.skipNextComputation + ? computeNextRunDate(schedule.cron, schedule.timezone, runCompletedAt) + : null; + const lastRunValue = + options.recordRun === false ? schedule.lastRunAt : runCompletedAt; + + await pgDb + .update(WorkflowScheduleTable) + .set({ + lastRunAt: lastRunValue, + nextRunAt, + lastError: options.errorMessage ?? null, + lockedAt: null, + lockedBy: null, + updatedAt: runCompletedAt, + }) + .where(eq(WorkflowScheduleTable.id, schedule.id)); +} diff --git a/src/lib/ai/workflow/workflow.interface.ts b/src/lib/ai/workflow/workflow.interface.ts index 4090743fc..7a8987d9e 100644 --- a/src/lib/ai/workflow/workflow.interface.ts +++ b/src/lib/ai/workflow/workflow.interface.ts @@ -1,6 +1,7 @@ import { Node } from "@xyflow/react"; import { ChatModel } from "app-types/chat"; import { ObjectJsonSchema7, TipTapMentionJsonContent } from "app-types/util"; +import { UIMessage } from "ai"; import { ConditionBranches } from "./condition"; import { JSONSchema7 } from "json-schema"; @@ -23,6 +24,42 @@ export enum NodeKind { Template = "template", // Template processing node Code = "code", // Code execution node (future implementation) Output = "output", // Exit point of workflow - produces final result + ReplyInThread = "reply-in-thread", // Create chat thread with predefined messages + Scheduler = "scheduler", // Configure cron-based workflow executions +} + +export type WorkflowExecutionContext = { + user?: { + id: string; + email?: string | null; + name?: string | null; + }; +}; + +export const WORKFLOW_CONTEXT_KEY = "__workflowContext" as const; + +export function withWorkflowContext( + query: Record | undefined, + context?: WorkflowExecutionContext, +): Record { + const baseQuery: Record = { ...(query ?? {}) }; + if (!context) { + return baseQuery; + } + + const existingContext = + typeof baseQuery[WORKFLOW_CONTEXT_KEY] === "object" && + baseQuery[WORKFLOW_CONTEXT_KEY] !== null + ? (baseQuery[WORKFLOW_CONTEXT_KEY] as WorkflowExecutionContext) + : {}; + + baseQuery[WORKFLOW_CONTEXT_KEY] = { + ...existingContext, + ...context, + user: context.user ?? existingContext.user, + } as WorkflowExecutionContext; + + return baseQuery; } /** @@ -188,6 +225,25 @@ export type TemplateNodeData = BaseWorkflowNodeDataData<{ }; }; +export type ReplyInThreadNodeData = BaseWorkflowNodeDataData<{ + kind: NodeKind.ReplyInThread; +}> & { + title?: TipTapMentionJsonContent; + messages: { + role: UIMessage["role"]; + content?: TipTapMentionJsonContent; + }[]; +}; + +export type SchedulerNodeData = BaseWorkflowNodeDataData<{ + kind: NodeKind.Scheduler; +}> & { + cron?: string; + timezone?: string; + enabled?: boolean; + payload?: Record; +}; + /** * Union type of all possible node data types. * When adding a new node type, include it in this union. @@ -200,7 +256,9 @@ export type WorkflowNodeData = | ToolNodeData | ConditionNodeData | HttpNodeData - | TemplateNodeData; + | TemplateNodeData + | ReplyInThreadNodeData + | SchedulerNodeData; /** * Runtime fields added during workflow execution diff --git a/src/lib/db/migrations/pg/0015_ordinary_captain_britain.sql b/src/lib/db/migrations/pg/0015_ordinary_captain_britain.sql new file mode 100644 index 000000000..85b7fff8f --- /dev/null +++ b/src/lib/db/migrations/pg/0015_ordinary_captain_britain.sql @@ -0,0 +1,22 @@ +CREATE TABLE "workflow_schedule" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workflow_id" uuid NOT NULL, + "workflow_node_id" uuid NOT NULL, + "cron" text NOT NULL, + "timezone" text DEFAULT 'UTC' NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "payload" json DEFAULT '{}'::json, + "next_run_at" timestamp, + "last_run_at" timestamp, + "last_error" text, + "locked_at" timestamp, + "locked_by" text, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "workflow_schedule_node_unique" UNIQUE("workflow_node_id") +); +--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD CONSTRAINT "workflow_schedule_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD CONSTRAINT "workflow_schedule_workflow_node_id_workflow_node_id_fk" FOREIGN KEY ("workflow_node_id") REFERENCES "public"."workflow_node"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workflow_schedule_workflow_idx" ON "workflow_schedule" USING btree ("workflow_id");--> statement-breakpoint +CREATE INDEX "workflow_schedule_next_run_idx" ON "workflow_schedule" USING btree ("next_run_at"); \ No newline at end of file diff --git a/src/lib/db/migrations/pg/meta/0015_snapshot.json b/src/lib/db/migrations/pg/meta/0015_snapshot.json new file mode 100644 index 000000000..f40fbd1f7 --- /dev/null +++ b/src/lib/db/migrations/pg/meta/0015_snapshot.json @@ -0,0 +1,1754 @@ +{ + "id": "9b0db0bb-312b-45a2-9f2f-50c6448580d2", + "prevId": "38d89506-17d0-44ef-89dd-625725e3bbfd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_user_id_user_id_fk": { + "name": "agent_user_id_user_id_fk", + "tableFrom": "agent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_item": { + "name": "archive_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archive_id": { + "name": "archive_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "archive_item_item_id_idx": { + "name": "archive_item_item_id_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_item_archive_id_archive_id_fk": { + "name": "archive_item_archive_id_archive_id_fk", + "tableFrom": "archive_item", + "tableTo": "archive", + "columnsFrom": ["archive_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "archive_item_user_id_user_id_fk": { + "name": "archive_item_user_id_user_id_fk", + "tableFrom": "archive_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive": { + "name": "archive", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "archive_user_id_user_id_fk": { + "name": "archive_user_id_user_id_fk", + "tableFrom": "archive", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmark": { + "name": "bookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "bookmark_user_id_idx": { + "name": "bookmark_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmark_item_idx": { + "name": "bookmark_item_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmark_user_id_user_id_fk": { + "name": "bookmark_user_id_user_id_fk", + "tableFrom": "bookmark", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bookmark_user_id_item_id_item_type_unique": { + "name": "bookmark_user_id_item_id_item_type_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "item_id", "item_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_export_comment": { + "name": "chat_export_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_export_comment_export_id_chat_export_id_fk": { + "name": "chat_export_comment_export_id_chat_export_id_fk", + "tableFrom": "chat_export_comment", + "tableTo": "chat_export", + "columnsFrom": ["export_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_export_comment_author_id_user_id_fk": { + "name": "chat_export_comment_author_id_user_id_fk", + "tableFrom": "chat_export_comment", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_export_comment_parent_id_chat_export_comment_id_fk": { + "name": "chat_export_comment_parent_id_chat_export_comment_id_fk", + "tableFrom": "chat_export_comment", + "tableTo": "chat_export_comment", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_export": { + "name": "chat_export", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exporter_id": { + "name": "exporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_thread_id": { + "name": "original_thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_export_exporter_id_user_id_fk": { + "name": "chat_export_exporter_id_user_id_fk", + "tableFrom": "chat_export", + "tableTo": "user", + "columnsFrom": ["exporter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_message": { + "name": "chat_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "json[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_message_thread_id_chat_thread_id_fk": { + "name": "chat_message_thread_id_chat_thread_id_fk", + "tableFrom": "chat_message", + "tableTo": "chat_thread", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_thread": { + "name": "chat_thread", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_thread_user_id_user_id_fk": { + "name": "chat_thread_user_id_user_id_fk", + "tableFrom": "chat_thread", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_session": { + "name": "mcp_oauth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_info": { + "name": "client_info", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "mcp_oauth_session_server_id_idx": { + "name": "mcp_oauth_session_server_id_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_session_state_idx": { + "name": "mcp_oauth_session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_session_tokens_idx": { + "name": "mcp_oauth_session_tokens_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_oauth_session\".\"tokens\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_session_mcp_server_id_mcp_server_id_fk": { + "name": "mcp_oauth_session_mcp_server_id_mcp_server_id_fk", + "tableFrom": "mcp_oauth_session", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_session_state_unique": { + "name": "mcp_oauth_session_state_unique", + "nullsNotDistinct": false, + "columns": ["state"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_custom_instructions": { + "name": "mcp_server_custom_instructions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_server_custom_instructions_user_id_user_id_fk": { + "name": "mcp_server_custom_instructions_user_id_user_id_fk", + "tableFrom": "mcp_server_custom_instructions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_custom_instructions_mcp_server_id_mcp_server_id_fk": { + "name": "mcp_server_custom_instructions_mcp_server_id_mcp_server_id_fk", + "tableFrom": "mcp_server_custom_instructions", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_server_custom_instructions_user_id_mcp_server_id_unique": { + "name": "mcp_server_custom_instructions_user_id_mcp_server_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "mcp_server_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server": { + "name": "mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_server_user_id_user_id_fk": { + "name": "mcp_server_user_id_user_id_fk", + "tableFrom": "mcp_server", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_tool_custom_instructions": { + "name": "mcp_server_tool_custom_instructions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_server_tool_custom_instructions_user_id_user_id_fk": { + "name": "mcp_server_tool_custom_instructions_user_id_user_id_fk", + "tableFrom": "mcp_server_tool_custom_instructions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_tool_custom_instructions_mcp_server_id_mcp_server_id_fk": { + "name": "mcp_server_tool_custom_instructions_mcp_server_id_mcp_server_id_fk", + "tableFrom": "mcp_server_tool_custom_instructions", + "tableTo": "mcp_server", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_server_tool_custom_instructions_user_id_tool_name_mcp_server_id_unique": { + "name": "mcp_server_tool_custom_instructions_user_id_tool_name_mcp_server_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "tool_name", "mcp_server_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edge": { + "name": "workflow_edge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0.1.0'" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ui_config": { + "name": "ui_config", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_edge_workflow_id_workflow_id_fk": { + "name": "workflow_edge_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edge", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edge_source_workflow_node_id_fk": { + "name": "workflow_edge_source_workflow_node_id_fk", + "tableFrom": "workflow_edge", + "tableTo": "workflow_node", + "columnsFrom": ["source"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edge_target_workflow_node_id_fk": { + "name": "workflow_edge_target_workflow_node_id_fk", + "tableFrom": "workflow_edge", + "tableTo": "workflow_node", + "columnsFrom": ["target"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_node": { + "name": "workflow_node", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0.1.0'" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_config": { + "name": "ui_config", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "node_config": { + "name": "node_config", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "workflow_node_kind_idx": { + "name": "workflow_node_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_node_workflow_id_workflow_id_fk": { + "name": "workflow_node_workflow_id_workflow_id_fk", + "tableFrom": "workflow_node", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_node_id": { + "name": "workflow_node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "workflow_schedule_workflow_idx": { + "name": "workflow_schedule_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_next_run_idx": { + "name": "workflow_schedule_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_workflow_node_id_workflow_node_id_fk": { + "name": "workflow_schedule_workflow_node_id_workflow_node_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_node", + "columnsFrom": ["workflow_node_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_node_unique": { + "name": "workflow_schedule_node_unique", + "nullsNotDistinct": false, + "columns": ["workflow_node_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0.1.0'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/db/migrations/pg/meta/_journal.json b/src/lib/db/migrations/pg/meta/_journal.json index 3a5bc3a85..df4b330a5 100644 --- a/src/lib/db/migrations/pg/meta/_journal.json +++ b/src/lib/db/migrations/pg/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1759110840795, "tag": "0014_faulty_gateway", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1763995755582, + "tag": "0015_ordinary_captain_britain", + "breakpoints": true } ] } diff --git a/src/lib/db/pg/repositories/workflow-repository.pg.ts b/src/lib/db/pg/repositories/workflow-repository.pg.ts index 1205bb7e6..d760770d2 100644 --- a/src/lib/db/pg/repositories/workflow-repository.pg.ts +++ b/src/lib/db/pg/repositories/workflow-repository.pg.ts @@ -5,6 +5,7 @@ import { WorkflowEdgeTable, WorkflowNodeDataTable, WorkflowTable, + WorkflowScheduleTable, } from "../schema.pg"; import { DBWorkflow, @@ -13,7 +14,11 @@ import { WorkflowRepository, WorkflowSummary, } from "app-types/workflow"; -import { NodeKind } from "lib/ai/workflow/workflow.interface"; +import { + NodeKind, + SchedulerNodeData, +} from "lib/ai/workflow/workflow.interface"; +import { computeNextRunDate } from "lib/ai/workflow/scheduler-utils"; import { createUINode } from "lib/ai/workflow/create-ui-node"; import { convertUINodeToDBNode, @@ -232,6 +237,13 @@ export const pgWorkflowRepository: WorkflowRepository = { updatedAt: new Date(), }, }); + + const schedulerNodes = nodes.filter( + (node) => node.kind === NodeKind.Scheduler, + ); + if (schedulerNodes.length) { + await upsertWorkflowSchedules(tx, workflowId, schedulerNodes); + } } if (edges?.length) { await tx.insert(WorkflowEdgeTable).values(edges).onConflictDoNothing(); @@ -269,3 +281,72 @@ export const pgWorkflowRepository: WorkflowRepository = { }; }, }; + +async function upsertWorkflowSchedules( + tx: any, + workflowId: string, + nodes: DBNode[], +) { + const now = new Date(); + const values = nodes + .map((node) => { + const config = (node.nodeConfig || {}) as Partial; + const cron = (config.cron || "").trim(); + if (!cron) { + return null; + } + const timezone = (config.timezone || "UTC").trim() || "UTC"; + const enabled = config.enabled ?? true; + const rawPayload = config.payload; + const payload = + rawPayload && + typeof rawPayload === "object" && + !Array.isArray(rawPayload) + ? rawPayload + : {}; + const nextRunAt = enabled + ? computeNextRunDate(cron, timezone, now) + : null; + + return { + workflowId, + workflowNodeId: node.id, + cron, + timezone, + enabled, + payload, + nextRunAt, + updatedAt: now, + }; + }) + .filter(Boolean) as { + workflowId: string; + workflowNodeId: string; + cron: string; + timezone: string; + enabled: boolean; + payload: Record; + nextRunAt: Date | null; + updatedAt: Date; + }[]; + + if (!values.length) return; + + await tx + .insert(WorkflowScheduleTable) + .values(values) + .onConflictDoUpdate({ + target: [WorkflowScheduleTable.workflowNodeId], + set: { + cron: sql.raw(`excluded.${WorkflowScheduleTable.cron.name}`), + timezone: sql.raw(`excluded.${WorkflowScheduleTable.timezone.name}`), + enabled: sql.raw(`excluded.${WorkflowScheduleTable.enabled.name}`), + payload: sql.raw(`excluded.${WorkflowScheduleTable.payload.name}`), + nextRunAt: sql.raw(`excluded.${WorkflowScheduleTable.nextRunAt.name}`), + updatedAt: sql.raw(`excluded.${WorkflowScheduleTable.updatedAt.name}`), + lockedAt: sql`NULL`, + lockedBy: sql`NULL`, + lastError: sql`NULL`, + }, + }); +} diff --git a/src/lib/db/pg/schema.pg.ts b/src/lib/db/pg/schema.pg.ts index 5c2e753b9..898af792e 100644 --- a/src/lib/db/pg/schema.pg.ts +++ b/src/lib/db/pg/schema.pg.ts @@ -265,6 +265,39 @@ export const WorkflowEdgeTable = pgTable("workflow_edge", { createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }); +export const WorkflowScheduleTable = pgTable( + "workflow_schedule", + { + id: uuid("id").primaryKey().notNull().defaultRandom(), + workflowId: uuid("workflow_id") + .notNull() + .references(() => WorkflowTable.id, { onDelete: "cascade" }), + workflowNodeId: uuid("workflow_node_id") + .notNull() + .references(() => WorkflowNodeDataTable.id, { onDelete: "cascade" }), + cron: text("cron").notNull(), + timezone: text("timezone").notNull().default("UTC"), + enabled: boolean("enabled").notNull().default(true), + payload: json("payload").$type>().default({}), + nextRunAt: timestamp("next_run_at"), + lastRunAt: timestamp("last_run_at"), + lastError: text("last_error"), + lockedAt: timestamp("locked_at"), + lockedBy: text("locked_by"), + createdAt: timestamp("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: timestamp("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + unique("workflow_schedule_node_unique").on(table.workflowNodeId), + index("workflow_schedule_workflow_idx").on(table.workflowId), + index("workflow_schedule_next_run_idx").on(table.nextRunAt), + ], +); + export const ArchiveTable = pgTable("archive", { id: uuid("id").primaryKey().notNull().defaultRandom(), name: text("name").notNull(), @@ -374,3 +407,4 @@ export const ChatExportCommentTable = pgTable("chat_export_comment", { export type ArchiveEntity = typeof ArchiveTable.$inferSelect; export type ArchiveItemEntity = typeof ArchiveItemTable.$inferSelect; export type BookmarkEntity = typeof BookmarkTable.$inferSelect; +export type WorkflowScheduleEntity = typeof WorkflowScheduleTable.$inferSelect; diff --git a/src/proxy.ts b/src/proxy.ts index 97122245d..d29f95ec3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -26,6 +26,6 @@ export async function proxy(request: NextRequest) { export const config = { matcher: [ - "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|api/auth|export|sign-in|sign-up).*)", + "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|api/auth|api/workflow/schedules/dispatch|export|sign-in|sign-up).*)", ], };
+ {t("Workflow.schedulerEnabledDescription") ?? + "Paused schedules will not run."} +
{payloadError}