diff --git a/src/lib/ai/tools/automation/pulse.ts b/src/lib/ai/tools/automation/pulse.ts
new file mode 100644
index 000000000..11c32e09c
--- /dev/null
+++ b/src/lib/ai/tools/automation/pulse.ts
@@ -0,0 +1,125 @@
+import { tool as createTool } from "ai";
+import { JSONSchema7 } from "json-schema";
+import { jsonSchemaToZod } from "lib/json-schema-to-zod";
+import { createPulseWorkflow } from "lib/ai/workflow/pulse-workflow";
+import { getRequestContext } from "lib/request-context";
+
+const pulseToolSchema: JSONSchema7 = {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ topic: {
+ type: "string",
+ description:
+ "Subject to monitor (e.g. `latest AI policy news`, `bitcoin regulation updates`).",
+ },
+ cron: {
+ type: "string",
+ description:
+ "Cron expression describing when the automation should run. Use standard 5-field cron such as `0 7 * * *`.",
+ },
+ timezone: {
+ type: "string",
+ description:
+ "IANA timezone for the schedule such as `America/New_York`. Defaults to the user's current timezone or UTC.",
+ },
+ workflowName: {
+ type: "string",
+ description:
+ "Optional friendly workflow name (defaults to `Pulse –
`).",
+ },
+ description: {
+ type: "string",
+ description: "Optional workflow description shown in the Workflow list.",
+ },
+ scheduleDescription: {
+ type: "string",
+ description: "Human readable description of what the schedule monitors.",
+ },
+ summaryInstructions: {
+ type: "string",
+ description:
+ "Custom instructions for how the summary should be written when the automation runs.",
+ },
+ numResults: {
+ type: "number",
+ description:
+ "How many web results to fetch on each run (1-10, default 6).",
+ minimum: 1,
+ maximum: 10,
+ default: 6,
+ },
+ },
+ required: ["topic", "cron"],
+};
+
+function isValidTimezone(tz: string | undefined): tz is string {
+ if (!tz) return false;
+ try {
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function clampNumber(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+function resolveTimezone(candidate?: string, fallback?: string): string {
+ if (isValidTimezone(candidate)) return candidate!;
+ if (isValidTimezone(fallback)) return fallback!;
+ return "UTC";
+}
+
+export const pulseTool = createTool({
+ description:
+ "Create a Pulse automation that monitors a topic on a recurring schedule. Provide a cron expression, timezone, and topic so the assistant can build a workflow that performs a web search, summarizes the findings, and replies in the thread only when meaningful new information is found. If no new info is gathered, the pulse silently skips sending a message.",
+ inputSchema: jsonSchemaToZod(pulseToolSchema),
+ async execute(input) {
+ const context = getRequestContext();
+ if (!context?.userId) {
+ throw new Error("Missing user context for Pulse automation.");
+ }
+
+ const topic = input.topic.trim();
+ const cron = input.cron.trim();
+ if (!topic) {
+ throw new Error("Topic cannot be empty.");
+ }
+ if (!cron) {
+ throw new Error("Cron expression cannot be empty.");
+ }
+
+ const timezone = resolveTimezone(input.timezone, context.clientTimezone);
+ const numResults = clampNumber(Math.round(input.numResults ?? 6), 1, 10);
+ const workflowName = (input.workflowName || `Pulse – ${topic}`).trim();
+
+ const result = await createPulseWorkflow({
+ userId: context.userId,
+ workflowName,
+ description: input.description,
+ query: topic,
+ cron,
+ timezone,
+ scheduleDescription:
+ input.scheduleDescription || `Monitor ${topic} (${cron} ${timezone})`,
+ summaryInstructions: input.summaryInstructions,
+ numResults,
+ model: context.chatModel,
+ });
+
+ return {
+ workflowId: result.workflowId,
+ scheduleNodeId: result.scheduleNodeId,
+ topic,
+ cron,
+ timezone,
+ nextRunAt: result.nextRunAt?.toISOString() ?? null,
+ numResults,
+ summaryInstructions: input.summaryInstructions,
+ message: `Created Pulse workflow "${workflowName}" for "${topic}". Next run: ${result.nextRunAt?.toISOString() ?? "unknown"}.`,
+ };
+ },
+});
diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts
index 233683d7a..34b7a249d 100644
--- a/src/lib/ai/tools/index.ts
+++ b/src/lib/ai/tools/index.ts
@@ -3,6 +3,7 @@ export enum AppDefaultToolkit {
WebSearch = "webSearch",
Http = "http",
Code = "code",
+ Automation = "automation",
}
export enum DefaultToolName {
@@ -15,6 +16,7 @@ export enum DefaultToolName {
Http = "http",
JavascriptExecution = "mini-javascript-execution",
PythonExecution = "python-execution",
+ Pulse = "pulse",
}
export const SequentialThinkingToolName = "sequential-thinking";
diff --git a/src/lib/ai/tools/tool-kit.ts b/src/lib/ai/tools/tool-kit.ts
index 22623a8e6..3ac1b70f6 100644
--- a/src/lib/ai/tools/tool-kit.ts
+++ b/src/lib/ai/tools/tool-kit.ts
@@ -8,6 +8,7 @@ import { Tool } from "ai";
import { httpFetchTool } from "./http/fetch";
import { jsExecutionTool } from "./code/js-run-tool";
import { pythonExecutionTool } from "./code/python-run-tool";
+import { pulseTool } from "./automation/pulse";
export const APP_DEFAULT_TOOL_KIT: Record<
AppDefaultToolkit,
@@ -30,4 +31,7 @@ export const APP_DEFAULT_TOOL_KIT: Record<
[DefaultToolName.JavascriptExecution]: jsExecutionTool,
[DefaultToolName.PythonExecution]: pythonExecutionTool,
},
+ [AppDefaultToolkit.Automation]: {
+ [DefaultToolName.Pulse]: pulseTool,
+ },
};
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/pulse-workflow.ts b/src/lib/ai/workflow/pulse-workflow.ts
new file mode 100644
index 000000000..664123abf
--- /dev/null
+++ b/src/lib/ai/workflow/pulse-workflow.ts
@@ -0,0 +1,355 @@
+import { ChatModel } from "app-types/chat";
+import { WorkflowIcon } from "app-types/workflow";
+import { ObjectJsonSchema7, TipTapMentionJsonContent } from "app-types/util";
+import { workflowRepository } from "lib/db/repository";
+import { createUINode } from "lib/ai/workflow/create-ui-node";
+import { NodeKind, UINode } from "./workflow.interface";
+import {
+ convertUINodeToDBNode,
+ defaultObjectJsonSchema,
+} from "./shared.workflow";
+import { DefaultToolName } from "lib/ai/tools";
+import { exaSearchSchema, exaSearchTool } from "lib/ai/tools/web/web-search";
+import { generateUUID } from "lib/utils";
+import { DBEdge, DBNode } from "app-types/workflow";
+import { computeNextRunDate } from "./scheduler-utils";
+import { ConditionBranch, BooleanConditionOperator } from "./condition";
+
+const DEFAULT_ICON: WorkflowIcon = {
+ type: "emoji",
+ value:
+ "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f916.png",
+ style: { backgroundColor: "oklch(87% 0 0)" },
+};
+
+const DEFAULT_MODEL: ChatModel = {
+ provider: "openai",
+ model: "gpt-4.1-mini",
+};
+
+type PulseWorkflowOptions = {
+ userId: string;
+ workflowName: string;
+ description?: string;
+ query: string;
+ cron: string;
+ timezone: string;
+ scheduleDescription?: string;
+ summaryInstructions?: string;
+ numResults?: number;
+ model?: ChatModel;
+};
+
+export type PulseWorkflowCreationResult = {
+ workflowId: string;
+ scheduleNodeId: string;
+ nextRunAt: Date | null;
+};
+
+export async function createPulseWorkflow(
+ options: PulseWorkflowOptions,
+): Promise {
+ const {
+ userId,
+ workflowName,
+ description,
+ query,
+ cron,
+ timezone,
+ scheduleDescription,
+ summaryInstructions,
+ numResults,
+ model,
+ } = options;
+
+ const nextRunAt = computeNextRunDate(cron, timezone);
+
+ const workflow = await workflowRepository.save(
+ {
+ name: workflowName,
+ description: description ?? `Pulse automation for ${query}`,
+ userId,
+ visibility: "private",
+ isPublished: true,
+ icon: DEFAULT_ICON,
+ },
+ true,
+ );
+
+ const inputNode = createUINode(NodeKind.Input, {
+ id: generateUUID(),
+ name: "INPUT",
+ position: { x: -180, y: 0 },
+ }) as UINode;
+ inputNode.data.outputSchema = {
+ ...(structuredClone(defaultObjectJsonSchema) as ObjectJsonSchema7),
+ properties: {
+ query: {
+ type: "string",
+ default: query,
+ description: "Search topic or request provided by the user",
+ },
+ },
+ required: ["query"],
+ };
+
+ const schedulerNode = createUINode(NodeKind.Scheduler, {
+ id: generateUUID(),
+ name: "SCHEDULER",
+ position: { x: -380, y: 200 },
+ }) as UINode;
+ schedulerNode.data.cron = cron;
+ schedulerNode.data.timezone = timezone;
+ schedulerNode.data.enabled = true;
+ schedulerNode.data.payload = {
+ query,
+ description: scheduleDescription ?? "Recurring Pulse schedule",
+ };
+
+ const toolNode = createUINode(NodeKind.Tool, {
+ id: generateUUID(),
+ name: "WEB_SEARCH",
+ position: { x: 200, y: 0 },
+ }) as UINode;
+ toolNode.data.model = model ?? DEFAULT_MODEL;
+ toolNode.data.tool = {
+ type: "app-tool",
+ id: DefaultToolName.WebSearch,
+ description: exaSearchTool.description!,
+ parameterSchema: exaSearchSchema,
+ };
+ toolNode.data.message = buildDoc([
+ paragraph([
+ text(
+ "Perform a focused web search for the following Pulse request. Return only the most recent, high-signal sources.",
+ ),
+ ]),
+ paragraph([mention(inputNode.id, ["query"])]),
+ paragraph([
+ text(
+ `Limit to ${numResults ?? 6} relevant results and prefer primary reporting or official data sources.`,
+ ),
+ ]),
+ ]);
+
+ const llmNode = createUINode(NodeKind.LLM, {
+ id: generateUUID(),
+ name: "SUMMARIZE",
+ position: { x: 560, y: 0 },
+ }) as UINode;
+ llmNode.data.model = model ?? DEFAULT_MODEL;
+ llmNode.data.outputSchema.properties.answer = {
+ type: "object",
+ properties: {
+ hasNewInfo: {
+ type: "boolean",
+ description:
+ "Indicates whether there is meaningful new information to report.",
+ },
+ answer: {
+ type: "string",
+ description: "The summary text of the latest findings.",
+ },
+ },
+ required: ["answer"],
+ };
+ llmNode.data.messages = [
+ {
+ role: "system",
+ content: buildDoc([
+ paragraph([
+ text(
+ summaryInstructions ||
+ "You are a monitoring assistant. Produce concise updates that highlight what changed since prior runs, cite notable sources, and suggest any next actions if relevant.",
+ ),
+ ]),
+ paragraph([
+ text(
+ 'IMPORTANT: Respond ONLY with valid JSON in this exact format: {"hasNewInfo": true/false, "answer": "your summary here"}. Set hasNewInfo to true only if there is meaningful new information worth reporting. If the search returned no results, errors, or nothing noteworthy, set hasNewInfo to false and provide a brief explanation in answer.',
+ ),
+ ]),
+ ]),
+ },
+ {
+ role: "user",
+ content: buildDoc([
+ paragraph([
+ text("Summarize the latest findings for "),
+ mention(inputNode.id, ["query"]),
+ text(". Reference key facts, trends, and actionable insights."),
+ ]),
+ paragraph([
+ text("Fresh research data: "),
+ mention(toolNode.id, ["tool_result"]),
+ ]),
+ paragraph([
+ text(
+ 'Remember to respond with JSON: {"hasNewInfo": boolean, "answer": string}',
+ ),
+ ]),
+ ]),
+ },
+ ];
+
+ const conditionNode = createUINode(NodeKind.Condition, {
+ id: generateUUID(),
+ name: "HAS_NEW_INFO",
+ position: { x: 900, y: 0 },
+ }) as UINode;
+ conditionNode.data.branches = {
+ if: {
+ id: "if",
+ logicalOperator: "AND",
+ type: "if",
+ conditions: [
+ {
+ source: {
+ nodeId: llmNode.id,
+ path: ["answer", "hasNewInfo"],
+ },
+ operator: BooleanConditionOperator.IsTrue,
+ },
+ ],
+ } as ConditionBranch,
+ else: {
+ id: "else",
+ logicalOperator: "AND",
+ type: "else",
+ conditions: [],
+ } as ConditionBranch,
+ };
+
+ const replyNode = createUINode(NodeKind.ReplyInThread, {
+ id: generateUUID(),
+ name: "REPLY",
+ position: { x: 1240, y: -100 },
+ }) as UINode;
+ replyNode.data.title = buildDoc([
+ paragraph([text("Pulse: "), mention(inputNode.id, ["query"])]),
+ ]);
+ replyNode.data.messages = [
+ {
+ role: "assistant",
+ content: buildDoc([
+ paragraph([
+ text("Pulse update for "),
+ mention(inputNode.id, ["query"]),
+ text(":"),
+ ]),
+ paragraph([mention(llmNode.id, ["answer", "answer"])]),
+ ]),
+ },
+ ];
+
+ const outputNode = createUINode(NodeKind.Output, {
+ id: generateUUID(),
+ name: "OUTPUT",
+ position: { x: 900, y: 200 },
+ }) as UINode;
+ outputNode.data.outputSchema = structuredClone(defaultObjectJsonSchema);
+ outputNode.data.outputData = [
+ {
+ key: "text",
+ source: {
+ nodeId: llmNode.id,
+ path: ["answer", "answer"],
+ },
+ },
+ ];
+
+ const nodes = [
+ inputNode,
+ toolNode,
+ llmNode,
+ conditionNode,
+ replyNode,
+ outputNode,
+ schedulerNode,
+ ];
+
+ const edges: DBEdge[] = [
+ createEdge(workflow.id, inputNode.id, toolNode.id),
+ createEdge(workflow.id, toolNode.id, llmNode.id),
+ createEdge(workflow.id, llmNode.id, conditionNode.id),
+ createEdge(workflow.id, conditionNode.id, replyNode.id, {
+ sourceHandle: "if",
+ }),
+ createEdge(workflow.id, conditionNode.id, outputNode.id, {
+ sourceHandle: "else",
+ }),
+ createEdge(workflow.id, conditionNode.id, outputNode.id, {
+ sourceHandle: "if",
+ }),
+ createEdge(workflow.id, inputNode.id, schedulerNode.id),
+ ];
+
+ const dbNodes: DBNode[] = nodes.map((node) => ({
+ ...convertUINodeToDBNode(workflow.id, node),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }));
+
+ await workflowRepository.saveStructure({
+ workflowId: workflow.id,
+ nodes: dbNodes,
+ edges,
+ });
+
+ return {
+ workflowId: workflow.id,
+ scheduleNodeId: schedulerNode.id,
+ nextRunAt,
+ };
+}
+
+type ParagraphNode = TipTapMentionJsonContent["content"][number];
+type ParagraphLeaf = NonNullable[number];
+type ParagraphContent = ParagraphLeaf[];
+
+function mention(nodeId: string, path: string[]): ParagraphLeaf {
+ return {
+ type: "mention",
+ attrs: {
+ id: generateUUID(),
+ label: JSON.stringify({ nodeId, path }),
+ },
+ };
+}
+
+function paragraph(content: ParagraphContent): ParagraphNode {
+ return {
+ type: "paragraph",
+ content,
+ };
+}
+
+function text(value: string): ParagraphLeaf {
+ return {
+ type: "text",
+ text: value,
+ };
+}
+
+function buildDoc(content: ParagraphNode[]): TipTapMentionJsonContent {
+ return {
+ type: "doc",
+ content,
+ };
+}
+
+function createEdge(
+ workflowId: string,
+ source: string,
+ target: string,
+ uiConfig: DBEdge["uiConfig"] = {},
+): DBEdge {
+ return {
+ id: generateUUID(),
+ workflowId,
+ source,
+ target,
+ uiConfig,
+ version: "0.1.0",
+ createdAt: new Date(),
+ } as DBEdge;
+}
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/lib/request-context.ts b/src/lib/request-context.ts
new file mode 100644
index 000000000..da659d49c
--- /dev/null
+++ b/src/lib/request-context.ts
@@ -0,0 +1,24 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+import type { ChatModel } from "app-types/chat";
+
+export type RequestContext = {
+ userId?: string;
+ userEmail?: string | null;
+ userName?: string | null;
+ threadId?: string;
+ clientTimezone?: string;
+ chatModel?: ChatModel;
+};
+
+const requestContextStorage = new AsyncLocalStorage();
+
+export function withRequestContext(
+ context: RequestContext,
+ callback: () => Promise | T,
+): Promise | T {
+ return requestContextStorage.run(context, callback);
+}
+
+export function getRequestContext(): RequestContext | undefined {
+ return requestContextStorage.getStore();
+}
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).*)",
],
};
diff --git a/src/types/chat.ts b/src/types/chat.ts
index 73b45a0de..c63653e5b 100644
--- a/src/types/chat.ts
+++ b/src/types/chat.ts
@@ -108,6 +108,7 @@ export const chatApiSchemaRequestBodySchema = z.object({
allowedMcpServers: z.record(z.string(), AllowedMCPServerZodSchema).optional(),
allowedAppDefaultToolkit: z.array(z.string()).optional(),
attachments: z.array(ChatAttachmentSchema).optional(),
+ clientTimezone: z.string().optional(),
});
export type ChatApiSchemaRequestBody = z.infer<