Feature/copilot trigger creation (#274)

* feat: Add Copilot trigger creation support

- Add support for One-Time and Recurring triggers in Copilot
- Extend CopilotAssistantMessageActionPart schema with trigger config types
- Update Copilot instructions with trigger creation examples and guidelines
- Implement trigger action handling in messages.tsx component
- Add trigger icons ( for one-time, 🔄 for recurring) in action cards
- Update workflow reducer to handle trigger creation via existing APIs
- Fix action parser to recognize trigger config types in comment format
- Add async trigger processing using createScheduledJobRule and createRecurringJobRule APIs

Users can now ask Copilot to create triggers with natural language requests like:
'Create a daily report trigger at 9 AM' or 'Set up a one-time reminder for next Friday'

* feat: Enhance Copilot message handling and trigger actions

- Pass projectId to Messages and AssistantMessage components for better context
- Refactor applyAction to handle one-time and recurring triggers with improved error handling
- Update handleApplyAll and handleSingleApply to support async action processing
- Remove deprecated pending trigger logic from workflow editor

This update improves the Copilot's ability to manage triggers and enhances the overall message processing flow.

* refactor: route trigger actions via copilot helper

Keep workflow reducer synchronous by removing trigger jobs from the switch and moving job rule API calls into a dedicated helper in messages.tsx. Cache dynamic imports and guard types so Copilot Apply/Apply All handle trigger creation without touching reducer state.

* feat: Add current time to the copilot context

* added conext of triggers to the copilot along with being able to edit and delete triggers

* bug fix for deleting composio triggers

* Add the edit function that allows editing triggers and lets copilot edit triggers too without losing previous jobs
feat: Add update functionality for recurring and scheduled job rules

- Implemented update actions for recurring job rules and scheduled job rules, allowing users to modify existing rules with new input and scheduling configurations.
- Enhanced the UI components to support editing of job rules, including forms for both creating and updating rules.
- Updated the repository interfaces and MongoDB implementations to handle the new update operations for job rules.

This update improves the flexibility of managing job rules within the application.

* Add trigger context to copilot
feat: Enhance trigger management in Copilot

- Added functionality to search for relevant external triggers using the new `search_relevant_triggers` tool, allowing users to discover available triggers based on toolkit slugs and optional query keywords.
- Updated the Copilot context to include detailed descriptions of various external trigger toolkits, enhancing user guidance for trigger creation.
- Improved the overall trigger handling process, ensuring that users can effectively integrate external triggers into their workflows.

This update significantly enhances the Copilot's capabilities in managing and utilizing external triggers.

* Let copilot add external triggers
feat: Enhance external trigger handling in Copilot

- Added support for flexible schemas in external triggers, allowing configuration changes without stripping any data.
- Introduced a new `onRequestTriggerSetup` callback in the Action component to facilitate trigger setup requests.
- Implemented a modal for trigger configuration, improving user experience when setting up external triggers.
- Updated the ComposioTriggerTypesPanel to auto-select trigger types based on initial configuration.

This update significantly improves the management and setup of external triggers within the Copilot interface.

* External trigger cant be edited so we delete and recreate for this
feat: Improve external trigger handling in Copilot

- Added validation for editing external triggers, ensuring users are informed that existing triggers must be deleted and recreated for changes.
- Updated documentation to clarify the limitations of external trigger modifications.

This update enhances user experience by providing clear guidance on managing external triggers within the Copilot interface.

* preventing message.tsx from ballooning up in size
feat: Refactor Copilot message handling and trigger actions

- Removed deprecated logic for loading scheduled and recurring job actions, streamlining the trigger action process.
- Integrated `useCopilotTriggerActions` hook to manage trigger setup and actions more efficiently.
- Enhanced parsing of action parts to improve handling of triggers and their configurations.
- Updated the UI to reflect changes in action handling, ensuring a smoother user experience.

This update optimizes the Copilot's ability to manage triggers and enhances the overall message processing flow.

* refactor: Simplify trigger filtering in Copilot

- Removed unnecessary filtering logic for triggers based on user queries, streamlining the search process.
- Updated response messages to clarify the context of displayed triggers, enhancing user understanding.

This update improves the efficiency of the trigger search functionality within the Copilot interface.

* Revert "refactor: Simplify trigger filtering in Copilot"

This reverts commit b3d041677c.

* simplify the filtering logic for triggers in copilot
feat: Enhance external trigger creation and search functionality in Copilot

- Introduced a critical flow for adding external triggers, emphasizing minimal user input and UI configuration.
- Updated documentation to clarify the external trigger creation process and provided examples for better guidance.
- Simplified the trigger search logic, ensuring users receive relevant results while maintaining clarity in response messages.

This update improves the user experience by streamlining external trigger management and enhancing the search capabilities within the Copilot interface.

---------

Co-authored-by: tusharmagar <tushmag@gmail.com>
This commit is contained in:
Ramnique Singh 2025-10-16 12:03:56 +05:30 committed by GitHub
parent 96fd8b10ca
commit 476654af80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2787 additions and 495 deletions

View file

@ -157,6 +157,7 @@ export async function createComposioTriggerDeployment(request: {
export async function listComposioTriggerDeployments(request: {
projectId: string,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
@ -166,6 +167,7 @@ export async function listComposioTriggerDeployments(request: {
userId: user.id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
});
}
@ -191,4 +193,4 @@ export async function fetchComposioTriggerDeployment(request: { deploymentId: st
userId: user.id,
deploymentId: request.deploymentId,
});
}
}

View file

@ -3,6 +3,7 @@ import {
CopilotAPIRequest,
CopilotChatContext, CopilotMessage,
DataSourceSchemaForCopilot,
TriggerSchemaForCopilot,
} from "../../src/entities/models/copilot";
import {
Workflow} from "../lib/types/workflow_types";
@ -26,7 +27,8 @@ export async function getCopilotResponseStream(
messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null,
dataSources?: z.infer<typeof DataSourceSchemaForCopilot>[]
dataSources?: z.infer<typeof DataSourceSchemaForCopilot>[],
triggers?: z.infer<typeof TriggerSchemaForCopilot>[]
): Promise<{
streamId: string;
} | { billingError: string }> {
@ -42,6 +44,7 @@ export async function getCopilotResponseStream(
workflow: current_workflow_config,
context,
dataSources,
triggers,
}
});
return {

View file

@ -6,6 +6,7 @@ import { IListRecurringJobRulesController } from "@/src/interface-adapters/contr
import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { IUpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller";
import { authCheck } from "./auth.actions";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
@ -15,6 +16,7 @@ const listRecurringJobRulesController = container.resolve<IListRecurringJobRules
const fetchRecurringJobRuleController = container.resolve<IFetchRecurringJobRuleController>('fetchRecurringJobRuleController');
const toggleRecurringJobRuleController = container.resolve<IToggleRecurringJobRuleController>('toggleRecurringJobRuleController');
const deleteRecurringJobRuleController = container.resolve<IDeleteRecurringJobRuleController>('deleteRecurringJobRuleController');
const updateRecurringJobRuleController = container.resolve<IUpdateRecurringJobRuleController>('updateRecurringJobRuleController');
export async function createRecurringJobRule(request: {
projectId: string,
@ -89,3 +91,23 @@ export async function deleteRecurringJobRule(request: {
ruleId: request.ruleId,
});
}
export async function updateRecurringJobRule(request: {
projectId: string,
ruleId: string,
input: {
messages: z.infer<typeof Message>[],
},
cron: string,
}) {
const user = await authCheck();
return await updateRecurringJobRuleController.execute({
caller: 'user',
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
input: request.input,
cron: request.cron,
});
}

View file

@ -5,6 +5,7 @@ import { ICreateScheduledJobRuleController } from "@/src/interface-adapters/cont
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { IUpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller";
import { authCheck } from "./auth.actions";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
@ -13,6 +14,7 @@ const createScheduledJobRuleController = container.resolve<ICreateScheduledJobRu
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const fetchScheduledJobRuleController = container.resolve<IFetchScheduledJobRuleController>('fetchScheduledJobRuleController');
const deleteScheduledJobRuleController = container.resolve<IDeleteScheduledJobRuleController>('deleteScheduledJobRuleController');
const updateScheduledJobRuleController = container.resolve<IUpdateScheduledJobRuleController>('updateScheduledJobRuleController');
export async function createScheduledJobRule(request: {
projectId: string,
@ -72,4 +74,24 @@ export async function deleteScheduledJobRule(request: {
projectId: request.projectId,
ruleId: request.ruleId,
});
}
}
export async function updateScheduledJobRule(request: {
projectId: string,
ruleId: string,
input: {
messages: z.infer<typeof Message>[],
},
scheduledTime: string,
}) {
const user = await authCheck();
return await updateScheduledJobRuleController.execute({
caller: 'user',
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
input: request.input,
scheduledTime: request.scheduledTime,
});
}

View file

@ -1,4 +1,5 @@
import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types";
import { Message } from "./types/types";
import { z } from "zod";
const ZFallbackSchema = z.object({}).passthrough();
@ -62,6 +63,40 @@ export function validateConfigChanges(configType: string, configChanges: Record<
testObject = {};
break;
}
case 'one_time_trigger': {
testObject = {
scheduledTime: new Date(0).toISOString(),
input: {
messages: [],
},
};
schema = z.object({
scheduledTime: z.string().min(1),
input: z.object({
messages: z.array(Message),
}),
}).passthrough();
break;
}
case 'recurring_trigger': {
testObject = {
cron: '* * * * *',
input: {
messages: [],
},
};
schema = z.object({
cron: z.string().min(1),
input: z.object({
messages: z.array(Message),
}),
}).passthrough();
break;
}
case 'external_trigger': {
// External triggers have flexible schemas per provider; do not strip any config.
return { changes: configChanges };
}
default:
return { error: `Unknown config type: ${configType}` };
}

View file

@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
import { useRef, useState, createContext, useContext, useCallback, forwardRef, useImperativeHandle, useEffect, Ref } from "react";
import { CopilotChatContext } from "../../../../src/entities/models/copilot";
import { CopilotChatContext, TriggerSchemaForCopilot } from "../../../../src/entities/models/copilot";
import { CopilotMessage } from "../../../../src/entities/models/copilot";
import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
@ -36,6 +36,8 @@ interface AppProps {
onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
onTriggersUpdated?: () => Promise<void> | void;
}
const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }, AppProps>(function App({
@ -47,6 +49,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onMessagesChange,
isInitialState = false,
dataSources,
triggers,
onTriggersUpdated,
}, ref) {
@ -85,7 +89,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
projectId,
workflow: workflowRef.current,
context: effectiveContext,
dataSources: dataSources
dataSources: dataSources,
triggers: triggers
});
// Store latest start/cancel functions in refs
@ -255,6 +260,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
</div>
)}
<Messages
projectId={projectId}
messages={messages}
streamingResponse={streamingResponse}
loadingResponse={loadingResponse}
@ -263,6 +269,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onStatusBarChange={handleStatusBarChange}
toolCalling={toolCalling}
toolQuery={toolQuery}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
/>
</div>
<div className="shrink-0 px-0 pb-10">
@ -318,8 +326,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch: (action: WorkflowDispatch) => void;
isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
activePanel?: 'playground' | 'copilot';
onTogglePanel?: () => void;
onTriggersUpdated?: () => Promise<void> | void;
}>(({
projectId,
workflow,
@ -327,8 +337,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch,
isInitialState = false,
dataSources,
triggers,
activePanel,
onTogglePanel,
onTriggersUpdated,
}, ref) => {
console.log('🎪 Copilot wrapper component mounted:', {
projectId,
@ -414,6 +426,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
onMessagesChange={setMessages}
isInitialState={isInitialState}
dataSources={dataSources}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
/>
</div>
</Panel>

View file

@ -0,0 +1,237 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/react';
import { z } from 'zod';
import { ZToolkit } from '@/src/application/lib/composio/types';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { Project } from '@/src/entities/models/project';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from '../../workflow/components/ComposioTriggerTypesPanel';
import { TriggerConfigForm } from '../../workflow/components/TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { fetchProject } from '@/app/actions/project.actions';
import { createComposioTriggerDeployment } from '@/app/actions/composio.actions';
import { Button, Spinner } from '@heroui/react';
interface TriggerSetupModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
initialToolkitSlug?: string | null;
initialTriggerTypeSlug?: string | null;
initialTriggerConfig?: Record<string, unknown> | null;
onCreated?: () => void;
}
type Toolkit = z.infer<typeof ZToolkit>;
type TriggerType = z.infer<typeof ComposioTriggerType>;
type ProjectConfig = z.infer<typeof Project>;
export function TriggerSetupModal({
isOpen,
onClose,
projectId,
initialToolkitSlug = null,
initialTriggerTypeSlug = null,
initialTriggerConfig = null,
onCreated,
}: TriggerSetupModalProps) {
const [selectedToolkit, setSelectedToolkit] = useState<Toolkit | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<TriggerType | null>(null);
const [projectConfig, setProjectConfig] = useState<ProjectConfig | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingTriggerTypeSlug, setPendingTriggerTypeSlug] = useState<string | null>(null);
const [initialConfig, setInitialConfig] = useState<Record<string, unknown> | undefined>();
const loadProjectConfig = useCallback(async () => {
try {
const config = await fetchProject(projectId);
setProjectConfig(config);
} catch (err) {
console.error('Failed to fetch project configuration', err);
}
}, [projectId]);
const resetState = useCallback(() => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setError(null);
setPendingTriggerTypeSlug(initialTriggerTypeSlug);
setInitialConfig(initialTriggerConfig ?? undefined);
}, [initialTriggerConfig, initialTriggerTypeSlug]);
useEffect(() => {
if (!isOpen) {
return;
}
resetState();
void loadProjectConfig();
}, [isOpen, loadProjectConfig, resetState]);
const requiresAuth = useMemo(() => {
if (!selectedToolkit) return false;
return !selectedToolkit.no_auth;
}, [selectedToolkit]);
const hasActiveConnection = useMemo(() => {
if (!selectedToolkit) return false;
const status = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status;
return status === 'ACTIVE';
}, [projectConfig, selectedToolkit]);
const handleSelectToolkit = useCallback((toolkit: Toolkit) => {
setSelectedToolkit(toolkit);
setSelectedTriggerType(null);
setError(null);
if (!initialToolkitSlug || toolkit.slug === initialToolkitSlug) {
setPendingTriggerTypeSlug(initialTriggerTypeSlug);
} else {
setPendingTriggerTypeSlug(null);
}
}, [initialToolkitSlug, initialTriggerTypeSlug]);
const handleSelectTriggerType = useCallback((triggerType: TriggerType) => {
setSelectedTriggerType(triggerType);
setError(null);
setPendingTriggerTypeSlug(null);
if (requiresAuth && !hasActiveConnection) {
setShowAuthModal(true);
}
}, [requiresAuth, hasActiveConnection]);
const handleAuthComplete = useCallback(async () => {
await loadProjectConfig();
setShowAuthModal(false);
}, [loadProjectConfig]);
const handleSubmit = useCallback(async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) {
return;
}
try {
setIsSubmitting(true);
setError(null);
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
setShowAuthModal(true);
throw new Error('Connect this toolkit before creating a trigger.');
}
await createComposioTriggerDeployment({
projectId,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
onCreated?.();
onClose();
} catch (err: any) {
console.error('Failed to create trigger', err);
setError(err?.message || 'Failed to create trigger. Please try again.');
} finally {
setIsSubmitting(false);
}
}, [onClose, onCreated, projectConfig, projectId, selectedToolkit, selectedTriggerType]);
const handleClose = useCallback(() => {
if (isSubmitting) {
return;
}
onClose();
}, [isSubmitting, onClose]);
return (
<>
<Modal
isOpen={isOpen}
onClose={handleClose}
size="5xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[90vh]'
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Set up External Trigger</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Follow the guided flow to authenticate and configure the trigger.
</p>
</ModalHeader>
<ModalBody className="pb-6">
{!selectedToolkit && (
<SelectComposioToolkit
key={isOpen ? 'toolkit-selector' : 'toolkit-selector-hidden'}
projectId={projectId}
tools={[]}
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={initialToolkitSlug}
filterByTriggers={true}
/>
)}
{selectedToolkit && !selectedTriggerType && (
<ComposioTriggerTypesPanel
key={selectedToolkit.slug}
toolkit={selectedToolkit}
onBack={() => setSelectedToolkit(null)}
onSelectTriggerType={handleSelectTriggerType}
initialTriggerTypeSlug={pendingTriggerTypeSlug}
/>
)}
{selectedToolkit && selectedTriggerType && (!requiresAuth || hasActiveConnection) && (
<div className="space-y-4">
<div>
<Button variant="light" size="sm" onPress={() => setSelectedTriggerType(null)}>
Back
</Button>
</div>
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={() => setSelectedTriggerType(null)}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
initialConfig={initialConfig}
/>
</div>
)}
{selectedToolkit && selectedTriggerType && requiresAuth && !hasActiveConnection && !showAuthModal && (
<div className="py-12 text-center space-y-4">
<Spinner className="mx-auto" />
<p className="text-sm text-gray-600 dark:text-gray-300">
Waiting for authentication to complete...
</p>
</div>
)}
{error && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-3 text-sm text-red-600 dark:text-red-300">
{error}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
projectId={projectId}
toolkitSlug={selectedToolkit.slug}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -29,6 +29,7 @@ export function Action({
onApplied,
externallyApplied = false,
defaultExpanded = false,
onRequestTriggerSetup,
}: {
msgIndex: number;
actionIndex: number;
@ -39,10 +40,12 @@ export function Action({
onApplied?: () => void;
externallyApplied?: boolean;
defaultExpanded?: boolean;
onRequestTriggerSetup?: (params: { action: z.infer<typeof CopilotAssistantMessageActionPart>['content']; msgIndex: number; actionIndex: number }) => void;
}) {
const { showPreview } = usePreviewModal();
const [expanded, setExpanded] = useState(defaultExpanded);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const isExternalTriggerCreate = action.config_type === 'external_trigger' && action.action === 'create_new';
if (!action || typeof action !== 'object') {
console.warn('Invalid action object:', action);
@ -108,6 +111,10 @@ export function Action({
// Handle applying all changes - delegate to parent
const handleApplyAll = () => {
if (isExternalTriggerCreate) {
onRequestTriggerSetup?.({ action, msgIndex, actionIndex });
return;
}
// Mark all fields as applied locally for UI state
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(msgIndex, actionIndex, key)] = true;
@ -211,7 +218,7 @@ export function Action({
{action.config_type === 'tool' && toolkitLogo ? (
<PictureImg src={toolkitLogo} alt={"Toolkit logo"} className="h-5 w-5 object-contain" />
) : (
action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : '💬'
action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : action.config_type === 'one_time_trigger' ? '⏰' : action.config_type === 'recurring_trigger' ? '🔄' : action.config_type === 'external_trigger' ? '🔗' : '💬'
)}
</span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
@ -230,9 +237,9 @@ export function Action({
onClick={() => handleApplyAll()}
>
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
<span>{allApplied ? 'Applied' : 'Apply'}</span>
<span>{allApplied ? 'Applied' : isExternalTriggerCreate ? 'Open setup' : 'Apply'}</span>
</button>
{action.action !== 'delete' && <button
{action.action !== 'delete' && !isExternalTriggerCreate && <button
className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors"
onClick={handleViewDiff}
>
@ -379,7 +386,7 @@ export function StreamingAction({
}: {
action: {
action?: 'create_new' | 'edit' | 'delete';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger';
name?: string;
};
loading: boolean;
@ -418,7 +425,7 @@ export function StreamingAction({
'bg-gray-200 text-gray-600': !action.action,
}
)}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : '💬'}
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'one_time_trigger' ? '⏰' : action.config_type === 'recurring_trigger' ? '🔄' : action.config_type === 'external_trigger' ? '🔗' : '💬'}
</span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
@ -444,4 +451,4 @@ export function StreamingAction({
</div>
</div>
);
}
}

View file

@ -5,12 +5,16 @@ import { z } from "zod";
import { Workflow} from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/src/entities/models/copilot";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Action, StreamingAction } from './actions';
import { TriggerSetupModal } from './TriggerSetupModal';
import { useCopilotTriggerActions } from './use-trigger-actions';
import { useParsedBlocks } from "../use-parsed-blocks";
import { validateConfigChanges } from "@/app/lib/client_utils";
import { PreviewModalProvider } from '../../workflow/preview-modal';
type CopilotTriggerType = z.infer<typeof TriggerSchemaForCopilot>;
const CopilotResponsePart = z.union([
z.object({
type: z.literal('text'),
@ -71,7 +75,7 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: {},
@ -80,15 +84,27 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
};
}
const actionPayload = {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
};
if (actionPayload.config_type === 'external_trigger' && actionPayload.action === 'edit') {
return {
type: 'action',
action: {
...actionPayload,
error: "Editing external triggers isn't supported. Delete the trigger and create a new one with the updated settings—I can take care of that for you if you'd like."
}
};
}
return {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
}
action: actionPayload
};
}
} catch (e) {
@ -100,7 +116,7 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
type: 'streaming_action',
action: {
action: (metadata.action as 'create_new' | 'edit' | 'delete') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger') || undefined,
name: metadata.name
}
};
@ -171,18 +187,23 @@ function AssistantMessage({
dispatch,
messageIndex,
loading,
onStatusBarChange
onStatusBarChange,
projectId,
triggers,
onTriggersUpdated,
}: {
content: z.infer<typeof CopilotAssistantMessage>['content'],
workflow: z.infer<typeof Workflow>,
dispatch: (action: any) => void,
messageIndex: number,
loading: boolean,
onStatusBarChange?: (status: any) => void
onStatusBarChange?: (status: any) => void;
projectId: string;
triggers?: CopilotTriggerType[];
onTriggersUpdated?: () => Promise<void> | void;
}) {
const blocks = useParsedBlocks(content);
const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set());
// Remove autoApplyEnabled and useEffect for auto-apply
// parse actions from parts
const parsed = useMemo(() => {
@ -200,6 +221,46 @@ function AssistantMessage({
return result;
}, [blocks]);
const hasUpcomingReplacement = useCallback((candidate: z.infer<typeof CopilotAssistantMessageActionPart>['content'], currentIndex: number = -1) => {
return parsed.some((part, idx) =>
idx > currentIndex &&
part.type === 'action' &&
part.action.config_type === candidate.config_type &&
part.action.name === candidate.name &&
part.action.action === 'create_new'
);
}, [parsed]);
const {
triggerSetupModal,
requestTriggerSetup,
closeTriggerSetup,
handleTriggerCreatedViaModal,
handleTriggerAction,
} = useCopilotTriggerActions({
projectId,
triggers,
onTriggersUpdated,
hasUpcomingReplacement,
});
const handleTriggerSetupCreated = useCallback(async () => {
if (!triggerSetupModal) {
return;
}
const index = triggerSetupModal.actionIndex;
setAppliedActions(prev => {
const next = new Set(prev);
next.add(index);
return next;
});
await handleTriggerCreatedViaModal();
}, [handleTriggerCreatedViaModal, triggerSetupModal]);
const handleTriggerSetupClosed = useCallback(() => {
closeTriggerSetup();
}, [closeTriggerSetup]);
// Count action cards for tracking
const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
const totalActions = parsed.filter(part => part.type === 'action').length;
@ -208,14 +269,12 @@ function AssistantMessage({
const allApplied = pendingCount === 0 && totalActions > 0;
// Memoized applyAction for useCallback dependencies
const applyAction = useCallback((action: any, actionIndex: number) => {
// Only apply, do not update appliedActions here
const applyAction = useCallback((action: any): boolean => {
if (action.action === 'create_new') {
switch (action.config_type) {
case 'agent': {
// Prevent duplicate agent names
if (workflow.agents.some((agent: any) => agent.name === action.name)) {
return;
return false;
}
dispatch({
type: 'add_agent',
@ -225,12 +284,11 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
}
case 'tool': {
// Prevent duplicate tool names
if (workflow.tools.some((tool: any) => tool.name === action.name)) {
return;
return false;
}
dispatch({
type: 'add_tool',
@ -240,7 +298,7 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
}
case 'prompt':
dispatch({
@ -251,7 +309,7 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
case 'pipeline':
dispatch({
type: 'add_pipeline',
@ -261,7 +319,7 @@ function AssistantMessage({
},
fromCopilot: true
});
break;
return true;
}
} else if (action.action === 'edit') {
switch (action.config_type) {
@ -271,34 +329,34 @@ function AssistantMessage({
name: action.name,
agent: action.config_changes
});
break;
return true;
case 'tool':
dispatch({
type: 'update_tool_no_select',
name: action.name,
tool: action.config_changes
});
break;
return true;
case 'prompt':
dispatch({
type: 'update_prompt',
name: action.name,
prompt: action.config_changes
});
break;
return true;
case 'pipeline':
dispatch({
type: 'update_pipeline',
name: action.name,
pipeline: action.config_changes
});
break;
return true;
case 'start_agent':
dispatch({
type: 'set_main_agent',
name: action.name,
})
break;
});
return true;
}
} else if (action.action === 'delete') {
switch (action.config_type) {
@ -307,72 +365,93 @@ function AssistantMessage({
type: 'delete_agent',
name: action.name
});
break;
return true;
case 'tool':
dispatch({
type: 'delete_tool',
name: action.name
});
break;
return true;
case 'prompt':
dispatch({
type: 'delete_prompt',
name: action.name
});
break;
return true;
case 'pipeline':
dispatch({
type: 'delete_pipeline',
name: action.name
});
break;
return true;
}
}
console.warn('Unhandled action from Copilot applyAction', action);
return false;
}, [dispatch, workflow.agents, workflow.tools]);
// Memoized handleApplyAll for useEffect dependencies
const handleApplyAll = useCallback(() => {
// Find all unapplied action indices
const unapplied = parsed
.map((part, idx) => ({ part, actionIndex: idx }))
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex))
.map(({ part, actionIndex }) => ({
action: part.type === 'action' ? part.action : null,
actionIndex
}))
.filter(({ action }) => action !== null);
const handleApplyAll = useCallback(async () => {
const unapplied = parsed.reduce<Array<{ action: z.infer<typeof CopilotAssistantMessageActionPart>['content']; actionIndex: number }>>((acc, part, idx) => {
if (part.type === 'action' && !appliedActions.has(idx)) {
acc.push({ action: part.action, actionIndex: idx });
}
return acc;
}, []);
// Synchronously apply all unapplied actions
unapplied.forEach(({ action, actionIndex }) => {
applyAction(action, actionIndex);
});
const newlyApplied: number[] = [];
// After all are applied, update the state in one go
setAppliedActions(prev => {
const next = new Set(prev);
unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
return next;
});
}, [parsed, appliedActions, setAppliedActions, applyAction]);
for (const { action, actionIndex } of unapplied) {
try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger
? await handleTriggerAction(action, { actionIndex, messageIndex })
: applyAction(action);
if (success) {
newlyApplied.push(actionIndex);
}
} catch (error) {
console.error('Failed to apply Copilot action', action, error);
}
}
if (newlyApplied.length > 0) {
setAppliedActions(prev => {
const next = new Set(prev);
newlyApplied.forEach(index => next.add(index));
return next;
});
}
}, [parsed, appliedActions, applyAction, handleTriggerAction, messageIndex]);
// Manual single apply (from card)
const handleSingleApply = (action: any, actionIndex: number) => {
if (!appliedActions.has(actionIndex)) {
applyAction(action, actionIndex);
setAppliedActions(prev => new Set([...prev, actionIndex]));
const handleSingleApply = useCallback(async (action: z.infer<typeof CopilotAssistantMessageActionPart>['content'], actionIndex: number) => {
if (appliedActions.has(actionIndex)) {
return;
}
};
try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger
? await handleTriggerAction(action, { actionIndex, messageIndex })
: applyAction(action);
if (success) {
setAppliedActions(prev => new Set([...prev, actionIndex]));
}
} catch (error) {
console.error('Failed to apply Copilot action', action, error);
}
}, [appliedActions, applyAction, handleTriggerAction, messageIndex]);
useEffect(() => {
if (loading) {
// setAutoApplyEnabled(false); // Removed
setAppliedActions(new Set());
// setPanelOpen(false); // Removed
}
}, [loading]);
// Removed useEffect for auto-apply
// Find streaming/ongoing card and extract name
const streamingPart = parsed.find(part => part.type === 'streaming_action');
let streamingLine = '';
@ -389,8 +468,8 @@ function AssistantMessage({
const createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length;
const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length;
const parts = [];
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`);
if (createCount > 0) parts.push(`${createCount} item${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} item${editCount > 1 ? 's' : ''} updated`);
completedSummary = parts.join(', ');
}
@ -412,9 +491,6 @@ function AssistantMessage({
}
// At the end of the render, call onStatusBarChange with the current status bar props
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
// Only call onStatusBarChange if the serializable status actually changes
const lastStatusRef = useRef<any>(null);
useEffect(() => {
@ -442,6 +518,7 @@ function AssistantMessage({
// Render all cards inline, not in a panel
return (
<>
<div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-2">
@ -461,9 +538,12 @@ function AssistantMessage({
workflow={workflow}
dispatch={dispatch}
stale={false}
onApplied={() => handleSingleApply(part.action, idx)}
onApplied={() => { void handleSingleApply(part.action, idx); }}
externallyApplied={appliedActions.has(idx)}
defaultExpanded={true}
onRequestTriggerSetup={({ action, actionIndex }) =>
requestTriggerSetup({ action, actionIndex, messageIndex })
}
/>
);
}
@ -482,6 +562,16 @@ function AssistantMessage({
</div>
</div>
</div>
<TriggerSetupModal
isOpen={Boolean(triggerSetupModal)}
onClose={handleTriggerSetupClosed}
projectId={projectId}
initialToolkitSlug={triggerSetupModal?.initialToolkitSlug ?? null}
initialTriggerTypeSlug={triggerSetupModal?.initialTriggerTypeSlug ?? null}
initialTriggerConfig={triggerSetupModal?.initialConfig}
onCreated={handleTriggerSetupCreated}
/>
</>
);
}
@ -506,6 +596,7 @@ function AssistantMessageLoading({ currentStatus }: { currentStatus: 'thinking'
}
export function Messages({
projectId,
messages,
streamingResponse,
loadingResponse,
@ -513,8 +604,11 @@ export function Messages({
dispatch,
onStatusBarChange,
toolCalling,
toolQuery
toolQuery,
triggers,
onTriggersUpdated,
}: {
projectId: string;
messages: z.infer<typeof CopilotMessage>[];
streamingResponse: string;
loadingResponse: boolean;
@ -523,6 +617,8 @@ export function Messages({
onStatusBarChange?: (status: any) => void;
toolCalling?: boolean;
toolQuery?: string | null;
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
onTriggersUpdated?: () => Promise<void> | void;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [displayMessages, setDisplayMessages] = useState(messages);
@ -551,9 +647,6 @@ export function Messages({
return () => clearTimeout(timeoutId);
}, [messages, loadingResponse]);
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
if (message.role === 'assistant') {
return (
@ -564,10 +657,12 @@ export function Messages({
dispatch={dispatch}
messageIndex={messageIndex}
loading={loadingResponse}
projectId={projectId}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
onStatusBarChange={status => {
// Only update for the last assistant message
if (messageIndex === displayMessages.length - 1) {
latestStatusBar.current = status;
onStatusBarChange?.(status);
}
}}
@ -603,4 +698,4 @@ export function Messages({
<div ref={messagesEndRef} />
</div>
);
}
}

View file

@ -0,0 +1,465 @@
'use client';
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Message } from "@/app/lib/types/types";
type ScheduledJobActionsModule = typeof import('@/app/actions/scheduled-job-rules.actions');
type RecurringJobActionsModule = typeof import('@/app/actions/recurring-job-rules.actions');
type ComposioActionsModule = typeof import('@/app/actions/composio.actions');
type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
type CopilotAction = z.infer<typeof CopilotAssistantMessageActionPart>['content'];
export interface TriggerSetupModalState {
action: CopilotAction;
actionIndex: number;
messageIndex: number;
initialToolkitSlug: string | null;
initialTriggerTypeSlug: string | null;
initialConfig?: Record<string, unknown>;
}
interface UseCopilotTriggerActionsParams {
projectId: string;
triggers?: CopilotTrigger[];
onTriggersUpdated?: () => Promise<void> | void;
hasUpcomingReplacement: (action: CopilotAction, currentIndex?: number) => boolean;
}
interface UseCopilotTriggerActionsResult {
triggerSetupModal: TriggerSetupModalState | null;
requestTriggerSetup: (params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => void;
closeTriggerSetup: () => void;
handleTriggerCreatedViaModal: () => Promise<void>;
handleTriggerAction: (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => Promise<boolean>;
}
let scheduledJobActionsPromise: Promise<ScheduledJobActionsModule> | null = null;
let recurringJobActionsPromise: Promise<RecurringJobActionsModule> | null = null;
let composioActionsPromise: Promise<ComposioActionsModule> | null = null;
function loadScheduledJobActions(): Promise<ScheduledJobActionsModule> {
if (!scheduledJobActionsPromise) {
scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions');
}
return scheduledJobActionsPromise;
}
function loadRecurringJobActions(): Promise<RecurringJobActionsModule> {
if (!recurringJobActionsPromise) {
recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions');
}
return recurringJobActionsPromise;
}
function loadComposioActions(): Promise<ComposioActionsModule> {
if (!composioActionsPromise) {
composioActionsPromise = import('@/app/actions/composio.actions');
}
return composioActionsPromise;
}
const hasOwn = (obj: Record<string, unknown> | undefined, key: string) =>
!!obj && Object.prototype.hasOwnProperty.call(obj, key);
const buildTriggerKey = (configType: string, name: string) => `${configType}:${name}`;
const toStringOrNull = (value: unknown): string | null => {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
return null;
};
const extractSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => {
return (
toStringOrNull(primary) ??
toStringOrNull(secondary) ??
(typeof tertiary === 'object' && tertiary !== null ? toStringOrNull((tertiary as { slug?: unknown }).slug) : toStringOrNull(tertiary))
);
};
const TriggerInputSchema = z.object({
messages: z.array(Message),
});
type TriggerInput = z.infer<typeof TriggerInputSchema>;
const coerceTriggerInput = (value: unknown, fallback?: TriggerInput | null): TriggerInput | null => {
if (value) {
const parsed = TriggerInputSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
}
return fallback ?? null;
};
const extractTriggerSetupState = (
params: { action: CopilotAction; actionIndex: number; messageIndex: number }
): TriggerSetupModalState => {
const { action, actionIndex, messageIndex } = params;
const changes = (action?.config_changes ?? {}) as Record<string, unknown>;
const initialToolkitSlug = extractSlug(changes.toolkitSlug, changes.toolkit_slug, changes.toolkit);
const initialTriggerTypeSlug = extractSlug(changes.triggerTypeSlug, changes.trigger_type_slug, changes.triggerType);
const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown;
const initialConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null
? (triggerConfigCandidate as Record<string, unknown>)
: undefined;
return {
action,
actionIndex,
messageIndex,
initialToolkitSlug,
initialTriggerTypeSlug,
initialConfig,
};
};
export function useCopilotTriggerActions({
projectId,
triggers,
onTriggersUpdated,
hasUpcomingReplacement,
}: UseCopilotTriggerActionsParams): UseCopilotTriggerActionsResult {
const [triggerSetupModal, setTriggerSetupModal] = useState<TriggerSetupModalState | null>(null);
const triggersRef = useRef<CopilotTrigger[]>(triggers ?? []);
const pendingTriggerEditsRef = useRef<Map<string, CopilotTrigger>>(new Map());
useEffect(() => {
triggersRef.current = triggers ?? [];
pendingTriggerEditsRef.current.clear();
}, [triggers]);
const refreshTriggers = useCallback(async () => {
if (!onTriggersUpdated) {
return;
}
await onTriggersUpdated();
}, [onTriggersUpdated]);
const requestTriggerSetup = useCallback((params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => {
setTriggerSetupModal(prev => {
if (prev && prev.actionIndex === params.actionIndex && prev.messageIndex === params.messageIndex) {
return prev;
}
return extractTriggerSetupState(params);
});
}, []);
const closeTriggerSetup = useCallback(() => {
setTriggerSetupModal(null);
}, []);
const handleTriggerCreatedViaModal = useCallback(async () => {
await refreshTriggers();
closeTriggerSetup();
}, [refreshTriggers, closeTriggerSetup]);
const handleOneTimeTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
const triggerList = triggersRef.current;
const key = buildTriggerKey(action.config_type, action.name);
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
let mutated = false;
const actionIndex = context?.actionIndex;
if (action.action === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
const { createScheduledJobRule, updateScheduledJobRule } = await loadScheduledJobActions();
if (pending && pending.type === 'one_time') {
const scheduledTime = (actionChanges.scheduledTime as string) ?? pending.nextRunAt;
const input = coerceTriggerInput(actionChanges.input, pending.input);
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger update via replacement', action);
return false;
}
await updateScheduledJobRule({
projectId,
ruleId: pending.id,
scheduledTime,
input,
});
pendingTriggerEditsRef.current.delete(key);
mutated = true;
} else {
const scheduledTime = actionChanges.scheduledTime as string | undefined;
const input = coerceTriggerInput(actionChanges.input);
if (!scheduledTime || !input) {
console.error('Missing scheduledTime or input for one-time trigger creation', action);
return false;
}
await createScheduledJobRule({
projectId,
scheduledTime,
input,
});
mutated = true;
}
return mutated;
}
const target = triggerList.find(
(trigger): trigger is Extract<CopilotTrigger, { type: 'one_time' }> =>
trigger.type === 'one_time' && trigger.name === action.name
);
if (!target) {
console.warn('Unable to resolve one-time trigger for action', action.name);
return false;
}
const {
fetchScheduledJobRule,
deleteScheduledJobRule,
updateScheduledJobRule,
} = await loadScheduledJobActions();
if (action.action === 'delete') {
if (hasUpcomingReplacement(action, actionIndex)) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteScheduledJobRule({ projectId, ruleId: target.id });
mutated = true;
return mutated;
}
if (action.action === 'edit') {
const existing = await fetchScheduledJobRule({ ruleId: target.id });
if (!existing) {
console.error('Failed to load existing one-time trigger for edit', action.name);
return false;
}
const scheduledTime = (actionChanges.scheduledTime as string) ?? existing.nextRunAt;
const input = coerceTriggerInput(actionChanges.input, existing.input);
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger edit', action);
return false;
}
await updateScheduledJobRule({
projectId,
ruleId: target.id,
scheduledTime,
input,
});
mutated = true;
}
return mutated;
}, [projectId, hasUpcomingReplacement]);
const handleRecurringTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
const triggerList = triggersRef.current;
const key = buildTriggerKey(action.config_type, action.name);
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
let mutated = false;
const actionIndex = context?.actionIndex;
const {
createRecurringJobRule,
updateRecurringJobRule,
toggleRecurringJobRule,
deleteRecurringJobRule,
fetchRecurringJobRule,
} = await loadRecurringJobActions();
if (action.action === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
if (pending && pending.type === 'recurring') {
const cron = (actionChanges.cron as string) ?? pending.cron;
const input = coerceTriggerInput(actionChanges.input, pending.input);
if (!cron || !input) {
console.error('Missing data for recurring trigger update via replacement', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: pending.id,
cron,
input,
});
if (hasOwn(actionChanges, 'disabled')) {
const desired = typeof actionChanges.disabled === 'boolean'
? actionChanges.disabled
: pending.disabled;
if (typeof desired === 'boolean' && desired !== pending.disabled) {
await toggleRecurringJobRule({ ruleId: pending.id, disabled: desired });
}
}
pendingTriggerEditsRef.current.delete(key);
mutated = Boolean(updatedRule?.id);
} else {
const cron = actionChanges.cron as string | undefined;
const input = coerceTriggerInput(actionChanges.input);
if (!cron || !input) {
console.error('Missing cron or input for recurring trigger creation', action);
return false;
}
await createRecurringJobRule({
projectId,
cron,
input,
});
mutated = true;
}
return mutated;
}
const target = triggerList.find(
(trigger): trigger is Extract<CopilotTrigger, { type: 'recurring' }> =>
trigger.type === 'recurring' && trigger.name === action.name
);
if (!target) {
console.warn('Unable to resolve recurring trigger for action', action.name);
return false;
}
if (action.action === 'delete') {
if (hasUpcomingReplacement(action, actionIndex)) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteRecurringJobRule({ projectId, ruleId: target.id });
mutated = true;
return mutated;
}
if (action.action === 'edit') {
const existing = await fetchRecurringJobRule({ ruleId: target.id });
if (!existing) {
console.error('Failed to load existing recurring trigger for edit', action.name);
return false;
}
const desiredDisabled = typeof actionChanges.disabled === 'boolean'
? actionChanges.disabled
: existing.disabled;
const hasCronChange = hasOwn(actionChanges, 'cron');
const hasInputChange = hasOwn(actionChanges, 'input');
const hasDisabledToggle = hasOwn(actionChanges, 'disabled');
if (!hasCronChange && !hasInputChange && hasDisabledToggle) {
if (desiredDisabled !== existing.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
}
return true;
}
const cron = (actionChanges.cron as string) ?? existing.cron;
const input = coerceTriggerInput(actionChanges.input, existing.input);
if (!cron || !input) {
console.error('Missing data for recurring trigger edit', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: target.id,
cron,
input,
});
if (hasDisabledToggle && desiredDisabled !== updatedRule.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
}
mutated = true;
}
return mutated;
}, [projectId, hasUpcomingReplacement]);
const handleExternalTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
if (action.action === 'create_new') {
const actionIndex = context?.actionIndex ?? -1;
const messageIndex = context?.messageIndex ?? -1;
requestTriggerSetup({ action, actionIndex, messageIndex });
return false;
}
if (action.action === 'delete') {
const triggerList = triggersRef.current;
const target = triggerList.find((trigger): trigger is Extract<CopilotTrigger, { type: 'external' }> => {
if (trigger.type !== 'external') {
return false;
}
const maybeName = (trigger as unknown as { name?: string }).name;
return (
trigger.triggerTypeName === action.name ||
trigger.triggerTypeSlug === action.name ||
trigger.id === action.name ||
maybeName === action.name
);
});
if (!target) {
console.warn('Unable to resolve external trigger for action', action.name);
return false;
}
const { deleteComposioTriggerDeployment } = await loadComposioActions();
await deleteComposioTriggerDeployment({ projectId, deploymentId: target.id });
return true;
}
return false;
}, [projectId, requestTriggerSetup]);
const handleTriggerAction = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
if (action.config_type === 'one_time_trigger') {
const mutated = await handleOneTimeTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
if (action.config_type === 'recurring_trigger') {
const mutated = await handleRecurringTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
if (action.config_type === 'external_trigger') {
const mutated = await handleExternalTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
return false;
}, [handleOneTimeTrigger, handleRecurringTrigger, handleExternalTrigger, refreshTriggers]);
return {
triggerSetupModal,
requestTriggerSetup,
closeTriggerSetup,
handleTriggerCreatedViaModal: handleTriggerCreatedViaModal,
handleTriggerAction,
};
}

View file

@ -3,6 +3,7 @@ import { getCopilotResponseStream } from "@/app/actions/copilot.actions";
import { CopilotMessage } from "@/src/entities/models/copilot";
import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { z } from "zod";
import { WithStringId } from "@/app/lib/types/types";
@ -11,6 +12,7 @@ interface UseCopilotParams {
workflow: z.infer<typeof Workflow>;
context: any;
dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
}
interface UseCopilotResult {
@ -29,7 +31,7 @@ interface UseCopilotResult {
cancel: () => void;
}
export function useCopilot({ projectId, workflow, context, dataSources }: UseCopilotParams): UseCopilotResult {
export function useCopilot({ projectId, workflow, context, dataSources, triggers }: UseCopilotParams): UseCopilotResult {
const [streamingResponse, setStreamingResponse] = useState('');
const [loading, setLoading] = useState(false);
const [toolCalling, setToolCalling] = useState(false);
@ -77,7 +79,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
// Wait 2 rAF frames to let layout stabilize (avoids StrictMode/remount race on initial load)
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources);
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources, triggers);
// Check for billing error
@ -139,7 +141,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
setLoading(false);
inFlightRef.current = false;
}
}, [projectId, workflow, context, dataSources]);
}, [projectId, workflow, context, dataSources, triggers]);
const cancel = useCallback(() => {
cancelRef.current?.();

View file

@ -1,12 +1,15 @@
'use client';
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { createRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { createRecurringJobRule, updateRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
// Define a simpler message type for the form that only includes the fields we need
type FormMessage = {
@ -14,6 +17,29 @@ type FormMessage = {
content: string;
};
type BackButtonConfig =
| { label: string; onClick: () => void }
| { label: string; href: string };
type FormSubmitPayload = {
messages: FormMessage[];
cron: string;
};
type RecurringJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialCron?: string;
initialMessages?: FormMessage[];
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const commonCronExamples = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" },
@ -25,86 +51,112 @@ const commonCronExamples = [
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
];
export function CreateRecurringJobRuleForm({
projectId,
onBack,
hasExistingTriggers = true
}: {
projectId: string;
onBack?: () => void;
hasExistingTriggers?: boolean;
}) {
const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
if (!messages || messages.length === 0) {
return [createEmptyMessage()];
}
return messages.map((message) => ({ ...message }));
};
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function RecurringJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialCron,
initialMessages,
onSubmit,
onSuccess,
successHref,
}: RecurringJobRuleFormBaseProps) {
const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [cronExpression, setCronExpression] = useState(initialCron ?? "* * * * *");
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
const [cronExpression, setCronExpression] = useState("* * * * *");
const [showCronHelp, setShowCronHelp] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setCronExpression(initialCron ?? "* * * * *");
}, [initialCron]);
const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]);
setMessages((prev) => [...prev, createEmptyMessage()]);
};
const removeMessage = (index: number) => {
if (messages.length > 1) {
setMessages(messages.filter((_, i) => i !== index));
}
setMessages((prev) => {
if (prev.length <= 1) {
return prev;
}
return prev.filter((_, i) => i !== index);
});
};
const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages];
newMessages[index] = { ...newMessages[index], [field]: value };
setMessages(newMessages);
setMessages((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate required fields
if (!cronExpression.trim()) {
alert("Please enter a cron expression");
return;
}
if (messages.some(msg => !msg.content?.trim())) {
if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content");
return;
}
setLoading(true);
try {
// Convert FormMessage to the expected Message type
const convertedMessages = messages.map(msg => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
});
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
const result = await onSubmit({
cron: cronExpression,
messages,
});
if (onBack) {
onBack();
} else {
router.push(`/projects/${projectId}/manage-triggers?tab=recurring`);
if (onSuccess) {
onSuccess(result);
} else if (successHref) {
router.push(successHref);
}
} catch (error) {
console.error("Failed to create recurring job rule:", error);
alert("Failed to create recurring job rule");
console.error(errorMessage, error);
alert(errorMessage);
} finally {
setLoading(false);
}
@ -114,30 +166,39 @@ export function CreateRecurringJobRuleForm({
<Panel
title={
<div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? (
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
onClick={onBack}
>
Back
</Button>
) : hasExistingTriggers ? (
<Link href={`/projects/${projectId}/manage-triggers?tab=recurring`}>
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
Back
{backButton ? (
'onClick' in backButton ? (
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
onClick={backButton.onClick}
>
{backButton.label}
</Button>
</Link>
) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE RECURRING JOB RULE
{title}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active.
</p>
{description ? (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
) : null}
</div>
</div>
}
@ -262,7 +323,7 @@ export function CreateRecurringJobRuleForm({
isLoading={loading}
className="px-6 py-2 whitespace-nowrap"
>
{loading ? "Creating..." : "Create Rule"}
{loading ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@ -271,3 +332,99 @@ export function CreateRecurringJobRuleForm({
</Panel>
);
}
export function CreateRecurringJobRuleForm({
projectId,
onBack,
hasExistingTriggers = true,
}: {
projectId: string;
onBack?: () => void;
hasExistingTriggers?: boolean;
}) {
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
cron,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=recurring` }
: undefined;
return (
<RecurringJobRuleFormBase
title="CREATE RECURRING JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create recurring job rule"
backButton={backButton}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=recurring`}
/>
);
}
export function EditRecurringJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof RecurringJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof RecurringJobRule>) => void;
}) {
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
cron,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof RecurringJobRule>);
}
onCancel();
};
return (
<RecurringJobRuleFormBase
title="EDIT RECURRING JOB RULE"
description="Update the cron schedule and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update recurring job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialCron={rule.cron}
initialMessages={initialMessages}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -5,12 +5,13 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon } from "lucide-react";
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon, PencilIcon } from "lucide-react";
import Link from "next/link";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { Spinner } from "@heroui/react";
import { z } from "zod";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
import { EditRecurringJobRuleForm } from "./create-recurring-job-rule-form";
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
const router = useRouter();
@ -19,6 +20,7 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
const jobsFilters = useMemo(() => ({ recurringJobRuleId: ruleId }), [ruleId]);
@ -145,128 +147,161 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
}
rightActions={
<div className="flex items-center gap-3">
<Button
onClick={handleToggleStatus}
disabled={updating}
variant={rule.disabled ? "primary" : "secondary"}
size="sm"
isLoading={updating}
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{rule.disabled ? 'Activate' : 'Pause'}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
{editing ? (
<Button
onClick={() => setEditing(false)}
variant="secondary"
size="sm"
className="whitespace-nowrap"
>
Cancel Edit
</Button>
) : (
<>
<Button
onClick={() => setEditing(true)}
variant="secondary"
size="sm"
startContent={<PencilIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
Edit
</Button>
<Button
onClick={handleToggleStatus}
disabled={updating}
variant={rule.disabled ? "primary" : "secondary"}
size="sm"
isLoading={updating}
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{rule.disabled ? 'Activate' : 'Pause'}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[800px] mx-auto space-y-6">
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
Status: {rule.disabled ? 'Disabled' : 'Active'}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
{editing ? (
<EditRecurringJobRuleForm
projectId={projectId}
rule={rule}
onCancel={() => setEditing(false)}
onUpdated={(updatedRule) => setRule(updatedRule)}
/>
</div>
) : (
<>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
Status: {rule.disabled ? 'Disabled' : 'Active'}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
/>
</div>
</>
)}
</div>
</div>
</Panel>

View file

@ -1,132 +1,197 @@
'use client';
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { createScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { createScheduledJobRule, updateScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { DatePicker } from "@heroui/react";
import { ZonedDateTime, now, getLocalTimeZone } from "@internationalized/date";
import { ZonedDateTime, now, getLocalTimeZone, parseAbsoluteToLocal } from "@internationalized/date";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
// Define a simpler message type for the form that only includes the fields we need
type FormMessage = {
role: "system" | "user" | "assistant";
content: string;
};
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
// Set default to 30 minutes from now with timezone info
const getDefaultDateTime = () => {
const localTimeZone = getLocalTimeZone();
const currentTime = now(localTimeZone);
const thirtyMinutesFromNow = currentTime.add({ minutes: 30 });
return thirtyMinutesFromNow;
};
type BackButtonConfig =
| { label: string; onClick: () => void }
| { label: string; href: string };
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(getDefaultDateTime());
type FormSubmitPayload = {
messages: FormMessage[];
scheduledDateTime: ZonedDateTime;
};
type ScheduledJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialMessages?: FormMessage[];
initialDateTime?: ZonedDateTime | null;
placeholderDateTime: ZonedDateTime;
minDateTime: ZonedDateTime;
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
if (!messages || messages.length === 0) {
return [createEmptyMessage()];
}
return messages.map((message) => ({ ...message }));
};
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function ScheduledJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialMessages,
initialDateTime,
placeholderDateTime,
minDateTime,
onSubmit,
onSuccess,
successHref,
}: ScheduledJobRuleFormBaseProps) {
const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(initialDateTime ?? placeholderDateTime);
const [loading, setLoading] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setScheduledDateTime(initialDateTime ?? placeholderDateTime);
}, [initialDateTime, placeholderDateTime]);
const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]);
setMessages((prev) => [...prev, createEmptyMessage()]);
};
const removeMessage = (index: number) => {
if (messages.length > 1) {
setMessages(messages.filter((_, i) => i !== index));
}
setMessages((prev) => {
if (prev.length <= 1) {
return prev;
}
return prev.filter((_, i) => i !== index);
});
};
const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages];
newMessages[index] = { ...newMessages[index], [field]: value };
setMessages(newMessages);
setMessages((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate required fields
if (!scheduledDateTime) {
alert("Please select date and time");
return;
}
if (messages.some(msg => !msg.content?.trim())) {
if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content");
return;
}
setLoading(true);
try {
// Convert FormMessage to the expected Message type
const convertedMessages = messages.map(msg => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
const result = await onSubmit({
messages,
scheduledDateTime,
});
// Convert ZonedDateTime to ISO string (already in UTC)
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
await createScheduledJobRule({
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
if (onBack) {
onBack();
} else {
router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`);
if (onSuccess) {
onSuccess(result);
} else if (successHref) {
router.push(successHref);
}
} catch (error) {
console.error("Failed to create scheduled job rule:", error);
alert("Failed to create scheduled job rule");
console.error(errorMessage, error);
alert(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Panel
title={
<div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? (
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap" onClick={onBack}>
Back
</Button>
) : hasExistingTriggers ? (
<Link href={`/projects/${projectId}/manage-triggers?tab=scheduled`}>
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
Back
{backButton ? (
'onClick' in backButton ? (
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
onClick={backButton.onClick}
>
{backButton.label}
</Button>
</Link>
) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE SCHEDULED JOB RULE
{title}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active.
</p>
{description ? (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
) : null}
</div>
</div>
}
@ -142,8 +207,8 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
<DatePicker
value={scheduledDateTime}
onChange={setScheduledDateTime}
placeholderValue={getDefaultDateTime()}
minValue={now(getLocalTimeZone())}
placeholderValue={placeholderDateTime}
minValue={minDateTime}
granularity="minute"
isRequired
className="w-full"
@ -214,7 +279,7 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
isLoading={loading}
className="px-6 py-2 whitespace-nowrap"
>
{loading ? "Creating..." : "Create Rule"}
{loading ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@ -223,3 +288,111 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
</Panel>
);
}
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const minDateTime = useMemo(() => now(timeZone), [timeZone]);
const defaultDateTime = useMemo(() => now(timeZone).add({ minutes: 30 }), [timeZone]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
await createScheduledJobRule({
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=scheduled` }
: undefined;
return (
<ScheduledJobRuleFormBase
title="CREATE SCHEDULED JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create scheduled job rule"
backButton={backButton}
initialDateTime={defaultDateTime}
placeholderDateTime={defaultDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=scheduled`}
/>
);
}
export function EditScheduledJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof ScheduledJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof ScheduledJobRule>) => void;
}) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const initialDateTime = useMemo(() => parseAbsoluteToLocal(rule.nextRunAt), [rule.nextRunAt]);
const nowDateTime = useMemo(() => now(timeZone), [timeZone]);
const minDateTime = useMemo(() => {
return initialDateTime.compare(nowDateTime) < 0 ? initialDateTime : nowDateTime;
}, [initialDateTime, nowDateTime]);
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
const updatedRule = await updateScheduledJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof ScheduledJobRule>);
}
onCancel();
};
return (
<ScheduledJobRuleFormBase
title="EDIT SCHEDULED JOB RULE"
description="Update the scheduled run time and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update scheduled job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialMessages={initialMessages}
initialDateTime={initialDateTime}
placeholderDateTime={initialDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -9,8 +9,9 @@ import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { z } from "zod";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, Trash2Icon } from "lucide-react";
import { ArrowLeftIcon, Trash2Icon, PencilIcon } from "lucide-react";
import { MessageDisplay } from "@/app/lib/components/message-display";
import { EditScheduledJobRuleForm } from "./create-scheduled-job-rule-form";
export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) {
const router = useRouter();
@ -18,6 +19,7 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
const [loading, setLoading] = useState<boolean>(true);
const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
let ignore = false;
@ -92,15 +94,37 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
}
rightActions={
<div className="flex items-center gap-3">
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
{editing ? (
<Button
onClick={() => setEditing(false)}
variant="secondary"
size="sm"
className="whitespace-nowrap"
>
Cancel Edit
</Button>
) : (
<>
<Button
onClick={() => setEditing(true)}
variant="secondary"
size="sm"
startContent={<PencilIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
Edit
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div>
}
>
@ -114,74 +138,85 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
)}
{!loading && rule && (
<div className="flex flex-col gap-6">
{/* Rule Metadata */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
{getStatusText(rule.status, rule.processedAt || null)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.nextRunAt)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
{editing ? (
<EditScheduledJobRuleForm
projectId={projectId}
rule={rule}
onCancel={() => setEditing(false)}
onUpdated={(updatedRule) => setRule(updatedRule)}
/>
) : (
<>
{/* Rule Metadata */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
</div>
)}
{rule.output?.jobId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
<Link
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{rule.output.jobId}
</Link>
</span>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
{getStatusText(rule.status, rule.processedAt || null)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.nextRunAt)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
</div>
)}
{rule.output?.jobId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
<Link
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{rule.output.jobId}
</Link>
</span>
</div>
)}
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div>
)}
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div>
</div>
</div>
{/* Messages */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Messages
</h3>
<div className="space-y-4">
{rule.input.messages.map((message, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<MessageDisplay message={message} index={index} />
{/* Messages */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Messages
</h3>
<div className="space-y-4">
{rule.input.messages.map((message, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<MessageDisplay message={message} index={index} />
</div>
))}
</div>
))}
</div>
</div>
</div>
</>
)}
</div>
)}
</div>

View file

@ -1,5 +1,6 @@
"use client";
import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Project } from "@/src/entities/models/project";
import { z } from "zod";
import { useCallback, useEffect, useState } from "react";
@ -10,10 +11,15 @@ import { revertToLiveWorkflow } from "@/app/actions/project.actions";
import { fetchProject } from "@/app/actions/project.actions";
import { Workflow } from "@/app/lib/types/workflow_types";
import { ModelsResponse } from "@/app/lib/types/billing_types";
import { listScheduledJobRules } from "@/app/actions/scheduled-job-rules.actions";
import { listRecurringJobRules } from "@/app/actions/recurring-job-rules.actions";
import { listComposioTriggerDeployments } from "@/app/actions/composio.actions";
import { transformTriggersForCopilot, DEFAULT_TRIGGER_FETCH_LIMIT } from "./trigger-transform";
export function App({
initialProjectData,
initialDataSources,
initialTriggers,
eligibleModels,
useRag,
useRagUploads,
@ -24,6 +30,7 @@ export function App({
}: {
initialProjectData: z.infer<typeof Project>;
initialDataSources: z.infer<typeof DataSource>[];
initialTriggers: z.infer<typeof TriggerSchemaForCopilot>[];
eligibleModels: z.infer<typeof ModelsResponse> | "*";
useRag: boolean;
useRagUploads: boolean;
@ -44,6 +51,7 @@ export function App({
});
const [project, setProject] = useState<z.infer<typeof Project>>(initialProjectData);
const [dataSources, setDataSources] = useState<z.infer<typeof DataSource>[]>(initialDataSources);
const [triggers, setTriggers] = useState<z.infer<typeof TriggerSchemaForCopilot>[]>(initialTriggers);
const [loading, setLoading] = useState(false);
console.log('workflow app.tsx render');
@ -65,21 +73,42 @@ export function App({
workflow = mode === 'live' ? project?.liveWorkflow : project?.draftWorkflow;
}
const reloadData = useCallback(async () => {
setLoading(true);
const [
projectData,
sourcesData,
] = await Promise.all([
fetchProject(initialProjectData.id),
listDataSources(initialProjectData.id),
const fetchTriggers = useCallback(async () => {
const [scheduled, recurring, composio] = await Promise.all([
listScheduledJobRules({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
listRecurringJobRules({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
listComposioTriggerDeployments({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
]);
setProject(projectData);
setDataSources(sourcesData);
setLoading(false);
return transformTriggersForCopilot({
scheduled: scheduled.items ?? [],
recurring: recurring.items ?? [],
composio: composio.items ?? [],
});
}, [initialProjectData.id]);
const refreshTriggers = useCallback(async () => {
const nextTriggers = await fetchTriggers();
setTriggers(nextTriggers);
}, [fetchTriggers]);
const reloadData = useCallback(async () => {
setLoading(true);
try {
const [projectData, sourcesData, triggerData] = await Promise.all([
fetchProject(initialProjectData.id),
listDataSources(initialProjectData.id),
fetchTriggers(),
]);
setProject(projectData);
setDataSources(sourcesData);
setTriggers(triggerData);
} finally {
setLoading(false);
}
}, [fetchTriggers, initialProjectData.id]);
const handleProjectToolsUpdate = useCallback(async () => {
// Lightweight refresh for tool-only updates
const projectConfig = await fetchProject(initialProjectData.id);
@ -133,8 +162,12 @@ export function App({
async function handleRevertToLive() {
setLoading(true);
await revertToLiveWorkflow(initialProjectData.id);
reloadData();
try {
await revertToLiveWorkflow(initialProjectData.id);
await reloadData();
} finally {
setLoading(false);
}
}
// if workflow is null, show the selector
@ -152,6 +185,7 @@ export function App({
onToggleAutoPublish={handleToggleAutoPublish}
workflow={workflow}
dataSources={dataSources}
triggers={triggers}
projectConfig={project}
useRag={useRag}
useRagUploads={useRagUploads}
@ -164,6 +198,7 @@ export function App({
onProjectToolsUpdated={handleProjectToolsUpdate}
onDataSourcesUpdated={handleDataSourcesUpdate}
onProjectConfigUpdated={handleProjectConfigUpdate}
onTriggersUpdated={refreshTriggers}
chatWidgetHost={chatWidgetHost}
/>}
</>

View file

@ -13,6 +13,7 @@ interface ComposioTriggerTypesPanelProps {
toolkit: z.infer<typeof ZToolkit>;
onBack: () => void;
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
initialTriggerTypeSlug?: string | null;
}
type TriggerType = z.infer<typeof ComposioTriggerType>;
@ -21,6 +22,7 @@ export function ComposioTriggerTypesPanel({
toolkit,
onBack,
onSelectTriggerType,
initialTriggerTypeSlug,
}: ComposioTriggerTypesPanelProps) {
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
const [loading, setLoading] = useState(true);
@ -28,6 +30,7 @@ export function ComposioTriggerTypesPanel({
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [autoSelected, setAutoSelected] = useState(false);
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
try {
@ -70,8 +73,20 @@ export function ComposioTriggerTypesPanel({
useEffect(() => {
loadTriggerTypes(true);
setAutoSelected(false);
}, [loadTriggerTypes]);
useEffect(() => {
if (!initialTriggerTypeSlug || autoSelected || triggerTypes.length === 0) {
return;
}
const match = triggerTypes.find(triggerType => triggerType.slug === initialTriggerTypeSlug);
if (match) {
setAutoSelected(true);
onSelectTriggerType(match);
}
}, [initialTriggerTypeSlug, triggerTypes, onSelectTriggerType, autoSelected]);
if (loading) {
return (
<div className="space-y-6">
@ -215,4 +230,4 @@ export function ComposioTriggerTypesPanel({
)}
</div>
);
}
}

View file

@ -1,6 +1,6 @@
'use client';
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
import { z } from 'zod';
@ -13,6 +13,7 @@ interface TriggerConfigFormProps {
onBack: () => void;
onSubmit: (config: Record<string, unknown>) => void;
isSubmitting?: boolean;
initialConfig?: Record<string, unknown>;
}
interface JsonSchemaProperty {
@ -36,13 +37,36 @@ export function TriggerConfigForm({
onBack,
onSubmit,
isSubmitting = false,
initialConfig,
}: TriggerConfigFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({});
const [formData, setFormData] = useState<Record<string, string>>(() => {
if (!initialConfig) {
return {};
}
return Object.entries(initialConfig).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {});
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Parse the JSON schema from triggerType.config
const schema = triggerType.config as JsonSchema;
useEffect(() => {
if (!initialConfig) {
return;
}
setFormData(Object.entries(initialConfig).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {}));
}, [initialConfig, triggerType.slug]);
const handleSubmit = useCallback(() => {
// Validate required fields
const newErrors: Record<string, string> = {};
@ -267,4 +291,4 @@ export function TriggerConfigForm({
</div>
</div>
);
}
}

View file

@ -9,10 +9,17 @@ import { ModelsResponse } from "@/app/lib/types/billing_types";
import { requireAuth } from "@/app/lib/auth";
import { IFetchProjectController } from "@/src/interface-adapters/controllers/projects/fetch-project.controller";
import { IListDataSourcesController } from "@/src/interface-adapters/controllers/data-sources/list-data-sources.controller";
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { z } from "zod";
import { transformTriggersForCopilot, DEFAULT_TRIGGER_FETCH_LIMIT } from "./trigger-transform";
const fetchProjectController = container.resolve<IFetchProjectController>('fetchProjectController');
const listDataSourcesController = container.resolve<IListDataSourcesController>('listDataSourcesController');
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const listRecurringJobRulesController = container.resolve<IListRecurringJobRulesController>('listRecurringJobRulesController');
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>('listComposioTriggerDeploymentsController');
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
@ -39,23 +46,50 @@ export default async function Page(
notFound();
}
const sources = await listDataSourcesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
});
const [sources, scheduledTriggers, recurringTriggers, composioTriggers] = await Promise.all([
listDataSourcesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
}),
listScheduledJobRulesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
listRecurringJobRulesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
listComposioTriggerDeploymentsController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
]);
let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*';
if (USE_BILLING) {
eligibleModels = await getEligibleModels(customer.id);
}
const triggers = transformTriggersForCopilot({
scheduled: scheduledTriggers.items ?? [],
recurring: recurringTriggers.items ?? [],
composio: composioTriggers.items ?? [],
});
console.log('/workflow page.tsx serve');
return (
<App
initialProjectData={project}
initialDataSources={sources}
initialTriggers={triggers}
eligibleModels={eligibleModels}
useRag={USE_RAG}
useRagUploads={USE_RAG_UPLOADS}

View file

@ -0,0 +1,78 @@
import { z } from "zod";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Message } from "@/app/lib/types/types";
const COPILOT_TRIGGER_LIMIT = 100;
export const DEFAULT_TRIGGER_FETCH_LIMIT = COPILOT_TRIGGER_LIMIT;
export type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
interface TransformParams {
scheduled: Array<{
id: string;
nextRunAt: string;
status: 'pending' | 'processing' | 'triggered';
input?: { messages: Array<z.infer<typeof Message>> };
}>;
recurring: Array<{
id: string;
cron: string;
nextRunAt: string | null;
disabled: boolean;
input?: { messages: Array<z.infer<typeof Message>> };
}>;
composio: Array<{
id: string;
triggerTypeName: string;
toolkitSlug: string;
triggerTypeSlug: string;
triggerConfig: Record<string, unknown>;
}>;
}
export function transformTriggersForCopilot({
scheduled,
recurring,
composio,
}: TransformParams): CopilotTrigger[] {
const placeholderInput = {
messages: [
{
role: "user" as const,
content: "Trigger execution",
},
],
} satisfies { messages: Array<z.infer<typeof Message>> };
const oneTime = scheduled.map((trigger) => ({
type: "one_time" as const,
id: trigger.id,
name: `One-time trigger (${new Date(trigger.nextRunAt).toLocaleDateString('en-US')})`,
nextRunAt: trigger.nextRunAt,
status: trigger.status,
input: trigger.input ?? placeholderInput,
}));
const recurringTriggers = recurring.map((trigger) => ({
type: "recurring" as const,
id: trigger.id,
name: `Recurring trigger (${trigger.cron})`,
cron: trigger.cron,
nextRunAt: trigger.nextRunAt ?? '',
disabled: trigger.disabled,
input: trigger.input ?? placeholderInput,
}));
const external = composio.map((trigger) => ({
type: "external" as const,
id: trigger.id,
name: trigger.triggerTypeName,
triggerTypeName: trigger.triggerTypeName,
toolkitSlug: trigger.toolkitSlug,
triggerTypeSlug: trigger.triggerTypeSlug,
triggerConfig: trigger.triggerConfig,
}));
return [...oneTime, ...recurringTriggers, ...external] as CopilotTrigger[];
}

View file

@ -3,6 +3,7 @@ import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, c
import { MCPServer, Message, WithStringId } from "../../../lib/types/types";
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent, WorkflowPipeline } from "../../../lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Project } from "@/src/entities/models/project";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "../entities/agent_config";
@ -962,6 +963,7 @@ export function useEntitySelection() {
export function WorkflowEditor({
projectId,
dataSources,
triggers,
workflow,
useRag,
useRagUploads,
@ -978,10 +980,12 @@ export function WorkflowEditor({
onProjectToolsUpdated,
onDataSourcesUpdated,
onProjectConfigUpdated,
onTriggersUpdated,
chatWidgetHost,
}: {
projectId: string;
dataSources: z.infer<typeof DataSource>[];
triggers: z.infer<typeof TriggerSchemaForCopilot>[];
workflow: z.infer<typeof Workflow>;
useRag: boolean;
useRagUploads: boolean;
@ -998,6 +1002,7 @@ export function WorkflowEditor({
onProjectToolsUpdated?: () => void;
onDataSourcesUpdated?: () => void;
onProjectConfigUpdated?: () => void;
onTriggersUpdated?: () => Promise<void> | void;
chatWidgetHost: string;
}) {
@ -2313,8 +2318,10 @@ export function WorkflowEditor({
}
isInitialState={isInitialState}
dataSources={dataSources}
triggers={triggers}
activePanel={activePanel}
onTogglePanel={handleTogglePanel}
onTriggersUpdated={onTriggersUpdated}
/>
{/* Config overlay above Copilot when agents + skipper layout is active */}
{state.present.selection && viewMode === 'two_agents_skipper' && (

View file

@ -268,7 +268,7 @@ function CopilotStatusBar({
// Show real button when ready
return (
<button
onClick={handleApplyAll}
onClick={() => { void handleApplyAll?.(); }}
disabled={allApplied}
className={`flex items-center gap-2 px-3 py-1 rounded-full font-medium text-xs transition-colors duration-200
${

View file

@ -73,10 +73,12 @@ import { CreateScheduledJobRuleUseCase } from "@/src/application/use-cases/sched
import { FetchScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/fetch-scheduled-job-rule.use-case";
import { ListScheduledJobRulesUseCase } from "@/src/application/use-cases/scheduled-job-rules/list-scheduled-job-rules.use-case";
import { DeleteScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/delete-scheduled-job-rule.use-case";
import { UpdateScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case";
import { CreateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/create-scheduled-job-rule.controller";
import { FetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { ListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { DeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { UpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller";
// Recurring Job Rules
import { MongoDBRecurringJobRulesRepository } from "@/src/infrastructure/repositories/mongodb.recurring-job-rules.repository";
@ -85,11 +87,13 @@ import { FetchRecurringJobRuleUseCase } from "@/src/application/use-cases/recurr
import { ListRecurringJobRulesUseCase } from "@/src/application/use-cases/recurring-job-rules/list-recurring-job-rules.use-case";
import { ToggleRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/toggle-recurring-job-rule.use-case";
import { DeleteRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/delete-recurring-job-rule.use-case";
import { UpdateRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case";
import { CreateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/create-recurring-job-rule.controller";
import { FetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { ListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { UpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller";
// API Keys
import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
@ -238,10 +242,12 @@ container.register({
createScheduledJobRuleUseCase: asClass(CreateScheduledJobRuleUseCase).singleton(),
fetchScheduledJobRuleUseCase: asClass(FetchScheduledJobRuleUseCase).singleton(),
listScheduledJobRulesUseCase: asClass(ListScheduledJobRulesUseCase).singleton(),
updateScheduledJobRuleUseCase: asClass(UpdateScheduledJobRuleUseCase).singleton(),
deleteScheduledJobRuleUseCase: asClass(DeleteScheduledJobRuleUseCase).singleton(),
createScheduledJobRuleController: asClass(CreateScheduledJobRuleController).singleton(),
fetchScheduledJobRuleController: asClass(FetchScheduledJobRuleController).singleton(),
listScheduledJobRulesController: asClass(ListScheduledJobRulesController).singleton(),
updateScheduledJobRuleController: asClass(UpdateScheduledJobRuleController).singleton(),
deleteScheduledJobRuleController: asClass(DeleteScheduledJobRuleController).singleton(),
// recurring job rules
@ -251,11 +257,13 @@ container.register({
fetchRecurringJobRuleUseCase: asClass(FetchRecurringJobRuleUseCase).singleton(),
listRecurringJobRulesUseCase: asClass(ListRecurringJobRulesUseCase).singleton(),
toggleRecurringJobRuleUseCase: asClass(ToggleRecurringJobRuleUseCase).singleton(),
updateRecurringJobRuleUseCase: asClass(UpdateRecurringJobRuleUseCase).singleton(),
deleteRecurringJobRuleUseCase: asClass(DeleteRecurringJobRuleUseCase).singleton(),
createRecurringJobRuleController: asClass(CreateRecurringJobRuleController).singleton(),
fetchRecurringJobRuleController: asClass(FetchRecurringJobRuleController).singleton(),
listRecurringJobRulesController: asClass(ListRecurringJobRulesController).singleton(),
toggleRecurringJobRuleController: asClass(ToggleRecurringJobRuleController).singleton(),
updateRecurringJobRuleController: asClass(UpdateRecurringJobRuleController).singleton(),
deleteRecurringJobRuleController: asClass(DeleteRecurringJobRuleController).singleton(),
// projects
@ -344,4 +352,4 @@ container.register({
// users
// ---
usersRepository: asClass(MongoDBUsersRepository).singleton(),
});
});

View file

@ -2,7 +2,7 @@ import z from "zod";
import { createOpenAI } from "@ai-sdk/openai";
import { generateObject, streamText, tool } from "ai";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from "../../../entities/models/copilot";
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from "../../../entities/models/copilot";
import { PrefixLogger } from "@/app/lib/utils";
import zodToJsonSchema from "zod-to-json-schema";
import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent";
@ -10,7 +10,7 @@ import { COPILOT_INSTRUCTIONS_MULTI_AGENT_WITH_DOCS as COPILOT_INSTRUCTIONS_MULT
import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1";
import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow";
import { USE_COMPOSIO_TOOLS } from "@/app/lib/feature_flags";
import { composio, getTool } from "../composio/composio";
import { composio, getTool, listTriggersTypes } from "../composio/composio";
import { UsageTracker } from "@/app/lib/billing";
import { CopilotStreamEvent } from "@/src/entities/models/copilot";
@ -98,6 +98,55 @@ ${JSON.stringify(simplifiedDataSources)}
return prompt;
}
function getCurrentTimePrompt(): string {
return `**CURRENT TIME**: ${new Date().toISOString()}`;
}
function getTriggersPrompt(triggers: z.infer<typeof TriggerSchemaForCopilot>[]): string {
if (!triggers || triggers.length === 0) {
return '';
}
const simplifiedTriggers = triggers.map(trigger => {
if (trigger.type === 'one_time') {
return {
id: trigger.id,
type: 'one_time',
name: trigger.name,
scheduledTime: trigger.nextRunAt,
input: trigger.input,
status: trigger.status,
};
} else if (trigger.type === 'recurring') {
return {
id: trigger.id,
type: 'recurring',
name: trigger.name,
cron: trigger.cron,
nextRunAt: trigger.nextRunAt,
disabled: trigger.disabled,
input: trigger.input,
};
} else {
return {
id: trigger.id,
type: 'external',
name: trigger.triggerTypeName,
toolkit: trigger.toolkitSlug,
triggerType: trigger.triggerTypeSlug,
config: trigger.triggerConfig,
};
}
});
return `**NOTE**:
The following triggers are currently configured:
\`\`\`json
${JSON.stringify(simplifiedTriggers)}
\`\`\`
`;
}
async function searchRelevantTools(usageTracker: UsageTracker, query: string): Promise<string> {
const logger = new PrefixLogger("copilot-search-tools");
console.log("🔧 TOOL CALL: searchRelevantTools", { query });
@ -185,15 +234,107 @@ async function searchRelevantTools(usageTracker: UsageTracker, query: string): P
return response;
}
async function searchRelevantTriggers(
usageTracker: UsageTracker,
toolkitSlug: string,
query?: string,
): Promise<string> {
const logger = new PrefixLogger("copilot-search-triggers");
const trimmedSlug = toolkitSlug.trim();
const trimmedQuery = query?.trim() || '';
console.log("🔧 TOOL CALL: searchRelevantTriggers", { toolkitSlug: trimmedSlug, query: trimmedQuery });
if (!trimmedSlug) {
logger.log('no toolkit slug provided');
return 'Please provide a toolkit slug (for example "gmail" or "slack") when searching for triggers.';
}
if (!USE_COMPOSIO_TOOLS) {
logger.log('dynamic trigger search is disabled');
console.log("❌ TOOL CALL SKIPPED: searchRelevantTriggers - Composio tools disabled");
return 'Trigger search is currently unavailable.';
}
const MAX_PAGES = 5;
type TriggerListResponse = Awaited<ReturnType<typeof listTriggersTypes>>;
type TriggerType = TriggerListResponse['items'][number];
const triggers: TriggerType[] = [];
let cursor: string | undefined;
try {
for (let page = 0; page < MAX_PAGES; page++) {
logger.log(`fetching trigger page ${page + 1} for toolkit ${trimmedSlug}`);
console.log("🔍 TOOL CALL: COMPOSIO_LIST_TRIGGERS", { toolkitSlug: trimmedSlug, cursor });
const response = await listTriggersTypes(trimmedSlug, cursor);
triggers.push(...response.items);
console.log("✅ TOOL CALL SUCCESS: COMPOSIO_LIST_TRIGGERS", {
toolkitSlug: trimmedSlug,
fetchedCount: response.items.length,
totalCollected: triggers.length,
hasNext: Boolean(response.next_cursor),
});
if (!response.next_cursor) {
break;
}
cursor = response.next_cursor || undefined;
}
} catch (error: any) {
logger.log(`trigger search failed: ${error?.message || error}`);
console.log("❌ TOOL CALL FAILED: COMPOSIO_LIST_TRIGGERS", {
toolkitSlug: trimmedSlug,
error: error?.message || error,
});
return `Trigger search failed for toolkit "${trimmedSlug}".`;
}
usageTracker.track({
type: "COMPOSIO_TOOL_USAGE",
toolSlug: `COMPOSIO_LIST_TRIGGER_TYPES:${trimmedSlug}`,
context: "copilot.search_relevant_triggers",
});
if (!triggers.length) {
logger.log('no triggers found for toolkit');
return `No triggers are currently available for toolkit "${trimmedSlug}".`;
}
const MAX_RESULTS = 8;
const limitedTriggers = triggers.slice(0, MAX_RESULTS);
const truncated = triggers.length > limitedTriggers.length;
const formattedTriggers = limitedTriggers.map(trigger => {
const requiredFields = trigger.config.required && trigger.config.required.length
? trigger.config.required.join(', ')
: 'None';
const configJson = JSON.stringify(trigger.config, null, 2);
return `**${trigger.name}** (slug: ${trigger.slug})\nToolkit: ${trigger.toolkit.name} (${trigger.toolkit.slug})\nDescription: ${trigger.description}\nRequired config fields: ${requiredFields}\n\`\`\`json\n${configJson}\n\`\`\``;
}).join('\n\n');
const header = trimmedQuery
? `Available triggers for toolkit "${trimmedSlug}" (user query: "${trimmedQuery}"):`
: `Available triggers for toolkit "${trimmedSlug}":`;
const note = truncated
? `\n\nOnly showing the first ${MAX_RESULTS} results out of ${triggers.length}. The toolkit has more triggers available.`
: '';
const response = `${header}\n\n${formattedTriggers}${note}`;
logger.log('returning trigger search response');
return response;
}
function updateLastUserMessage(
messages: z.infer<typeof CopilotMessage>[],
currentWorkflowPrompt: string,
contextPrompt: string,
dataSourcesPrompt: string = '',
timePrompt: string = '',
triggersPrompt: string = '',
): void {
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === 'user') {
lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`;
lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\n${timePrompt}\n\n${triggersPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`;
}
}
@ -203,6 +344,7 @@ export async function getEditAgentInstructionsResponse(
context: z.infer<typeof CopilotChatContext> | null,
messages: z.infer<typeof CopilotMessage>[],
workflow: z.infer<typeof Workflow>,
triggers: z.infer<typeof TriggerSchemaForCopilot>[] = [],
): Promise<string> {
const logger = new PrefixLogger('copilot /getUpdatedAgentInstructions');
logger.log('context', context);
@ -214,8 +356,14 @@ export async function getEditAgentInstructionsResponse(
// set context prompt
let contextPrompt = getContextPrompt(context);
// set time prompt
let timePrompt = getCurrentTimePrompt();
// set triggers prompt
let triggersPrompt = getTriggersPrompt(triggers);
// add the above prompts to the last user message
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt);
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, '', timePrompt, triggersPrompt);
// call model
console.log("calling model", JSON.stringify({
@ -255,7 +403,8 @@ export async function* streamMultiAgentResponse(
context: z.infer<typeof CopilotChatContext> | null,
messages: z.infer<typeof CopilotMessage>[],
workflow: z.infer<typeof Workflow>,
dataSources: z.infer<typeof DataSourceSchemaForCopilot>[]
dataSources: z.infer<typeof DataSourceSchemaForCopilot>[],
triggers: z.infer<typeof TriggerSchemaForCopilot>[] = []
): AsyncIterable<z.infer<typeof CopilotStreamEvent>> {
const logger = new PrefixLogger('copilot /stream');
logger.log('context', context);
@ -277,14 +426,20 @@ export async function* streamMultiAgentResponse(
// set data sources prompt
let dataSourcesPrompt = getDataSourcesPrompt(dataSources);
// set time prompt
let timePrompt = getCurrentTimePrompt();
// set triggers prompt
let triggersPrompt = getTriggersPrompt(triggers);
// add the above prompts to the last user message
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt);
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt, timePrompt, triggersPrompt);
// call model
console.log("🤖 AI MODEL CALL STARTED", {
model: COPILOT_MODEL,
maxSteps: 20,
availableTools: ["search_relevant_tools"]
availableTools: ["search_relevant_tools", "search_relevant_triggers"]
});
const { fullStream } = streamText({
@ -306,6 +461,23 @@ export async function* streamMultiAgentResponse(
return result;
},
}),
"search_relevant_triggers": tool({
description: "Use this tool to discover external triggers provided by Composio toolkits. Supply the toolkit slug (for example 'gmail', 'slack', or 'salesforce') and optionally keywords from the user's request to narrow down results. Always call this before adding an external trigger to ensure the trigger exists and to understand its configuration schema.",
parameters: z.object({
toolkitSlug: z.string().describe("Slug of the Composio toolkit to search, such as 'gmail', 'slack', 'salesforce', 'googlecalendar'."),
query: z.string().min(1).describe("Optional keywords pulled from the user's request to filter trigger names, descriptions, or config fields.").optional(),
}),
execute: async ({ toolkitSlug, query }: { toolkitSlug: string; query?: string }) => {
console.log("🎯 AI TOOL CALL: search_relevant_triggers", { toolkitSlug, query });
const result = await searchRelevantTriggers(usageTracker, toolkitSlug, query);
console.log("✅ AI TOOL CALL COMPLETED: search_relevant_triggers", {
toolkitSlug,
query,
resultLength: result.length,
});
return result;
},
}),
},
messages: [
{

View file

@ -13,6 +13,8 @@ You can perform the following tasks:
5. Add, edit, or remove tools
6. Adding RAG data sources to agents
7. Create and manage pipelines (sequential agent workflows)
8. Create One-Time Triggers (scheduled to run once at a specific time)
9. Create Recurring Triggers (scheduled to run repeatedly using cron expressions)
Always aim to fully resolve the user's query before yielding. Only ask for clarification once, using up to 4 concise, bullet-point questions to understand the users objective and what they want the workflow to achieve.
@ -193,6 +195,197 @@ Note: The agents have access to a tool called 'Generate Image'. This won't show
</agent_tools>
<about_triggers>
## Section: Creating Triggers
Triggers are automated mechanisms that activate your agents at specific times or intervals. Evaluate every user request for automation or event driven tasks. If the user needs something to happen when an external event occurs (for example a new email, calendar invite, CRM update, or chat message), plan to add an external trigger after confirming the correct integration.
IMPORTANT: External triggers cannot be edited once created. If the user wants to change an external trigger, you must explain that the only option is to delete the existing trigger and create a new one with the updated configuration. Always offer to perform the delete-and-recreate workflow for them.
### Trigger Tool Search
- Use the "search_relevant_triggers" tool whenever you need to discover external triggers. Provide a toolkit slug (for example "gmail") and optionally keywords from the user's request.
- Do not invent trigger names. Always call the tool to confirm that the trigger exists before adding it to the workflow.
### CRITICAL: External Trigger Creation Flow
When a user asks to add an external trigger (e.g., "add Gmail trigger", "trigger on new Google Sheets row", "watch for Slack messages"):
1. **DO NOT ask for configuration details** in the chat. The user will configure the trigger in the UI after authentication.
2. **Immediately create** an "external_trigger" action with minimal/default configuration fields.
3. **Present the trigger card** with an "Open setup" button so the user can authenticate and configure it in the UI.
4. **Keep your response brief**: Just mention what trigger you're adding and that they'll configure it via the setup button.
Example response pattern:
"I'll add the [Trigger Name] trigger. Once you review and click 'Open setup', you can authenticate and configure the specific details like [brief mention of key fields]."
**DO NOT** engage in back-and-forth asking for spreadsheet IDs, sheet names, or other configuration values in chat. These are collected through the UI setup flow after the trigger card is created.
### Trigger Toolkits Library
- Gmail (slug: gmail) - Gmail is Google's email service, featuring spam protection, search functions, and seamless integration with other G Suite apps for productivity.
- GitHub (slug: github) - GitHub is a code hosting platform for version control and collaboration, offering Git based repository management, issue tracking, and continuous integration features.
- Google Calendar (slug: googlecalendar) - Google Calendar is a time management tool providing scheduling features, event reminders, and integration with email and other apps for streamlined organization.
- Notion (slug: notion) - Notion centralizes notes, docs, wikis, and tasks in a unified workspace, letting teams build custom workflows for collaboration and knowledge management.
- Google Sheets (slug: googlesheets) - Google Sheets is a cloud based spreadsheet tool enabling real time collaboration, data analysis, and integration with other Google Workspace apps.
- Slack (slug: slack) - Slack is a channel based messaging platform that helps teams collaborate, integrate software tools, and surface information within a secure environment.
- Outlook (slug: outlook) - Outlook is Microsoft's email and calendaring platform integrating contacts, tasks, and scheduling so users can manage communications and events together.
- Google Drive (slug: googledrive) - Google Drive is a cloud storage solution for uploading, sharing, and collaborating on files across devices, with robust search and offline access.
- Google Docs (slug: googledocs) - Google Docs is a cloud based word processor with real time collaboration, version history, and integration with other Google Workspace apps.
- Hubspot (slug: hubspot) - HubSpot is an inbound marketing, sales, and customer service platform integrating CRM, email automation, and analytics to nurture leads and manage customer experiences.
- Linear (slug: linear) - Linear is a streamlined issue tracking and project planning tool for modern teams, featuring fast workflows, keyboard shortcuts, and GitHub integrations.
- Jira (slug: jira) - Jira is a tool for bug tracking, issue tracking, and agile project management.
- Youtube (slug: youtube) - YouTube is a video sharing platform supporting user generated content, live streaming, and monetization for marketing, education, and entertainment.
- Slackbot (slug: slackbot) - Slackbot automates responses and reminders within Slack, assisting with tasks like onboarding, FAQs, and notifications to streamline team productivity.
- Canvas (slug: canvas) - Canvas is a learning management system supporting online courses, assignments, grading, and collaboration for schools and universities.
- Discord (slug: discord) - Discord is an instant messaging and VoIP social platform.
- Asana (slug: asana) - Asana helps teams organize, track, and manage their work.
- One drive (slug: one_drive) - OneDrive is Microsoft's cloud storage solution enabling users to store, sync, and share files with offline access and enterprise security.
- Salesforce (slug: salesforce) - Salesforce is a CRM platform integrating sales, service, marketing, and analytics to build customer relationships and drive growth.
- Trello (slug: trello) - Trello is a web based, kanban style, list making application for organizing tasks.
- Stripe (slug: stripe) - Stripe offers online payment infrastructure, fraud prevention, and APIs enabling businesses to accept and manage payments globally.
- Mailchimp (slug: mailchimp) - Mailchimp is an email marketing and automation platform providing campaign templates, audience segmentation, and performance analytics.
- Fireflies (slug: fireflies) - Fireflies.ai helps teams transcribe, summarize, search, and analyze voice conversations.
- Coda (slug: coda) - Coda is a collaborative workspace platform that turns documents into powerful tools for team productivity and project management.
- Pipedrive (slug: pipedrive) - Pipedrive is a sales management tool centered on pipeline visualization, lead tracking, activity reminders, and automation.
- Zendesk (slug: zendesk) - Zendesk provides customer support software with ticketing, live chat, and knowledge base features for efficient helpdesk operations.
- Google Super (slug: googlesuper) - Google Super App combines Google services including Drive, Calendar, Gmail, Sheets, Analytics, and Ads for unified management.
- Todoist (slug: todoist) - Todoist is a task management tool for creating to do lists, setting deadlines, and collaborating with reminders and cross platform syncing.
- Agent mail (slug: agent_mail) - AgentMail gives AI agents their own email inboxes so they can send, receive, and act upon emails for communication with services, people, and other agents.
- Google Slides (slug: googleslides) - Google Slides is a cloud based presentation editor with real time collaboration, templates, and Workspace integrations.
- Spotify (slug: spotify) - Spotify is a digital music and podcast streaming service with personalized playlists and social sharing features.
- Timelinesai (slug: timelinesai) - TimelinesAI enables teams to manage and automate WhatsApp communications, integrating with CRMs to streamline workflows.
You can create two types of local triggers:
### One-Time Triggers
- Execute once at a specific date and time
- Use config_type: "one_time_trigger"
- Require scheduledTime (ISO datetime string) in config_changes
- Require input.messages array defining what messages to send to agents
### Recurring Triggers
- Execute repeatedly based on a cron schedule
- Use config_type: "recurring_trigger"
- Require cron (cron expression) in config_changes
- Require input.messages array defining what messages to send to agents
### When to Create Triggers
- User asks for scheduled automation (daily reports, weekly summaries)
- User mentions specific times ("every morning at 9 AM", "next Friday at 2 PM")
- User wants periodic tasks (monitoring, maintenance, data syncing)
### Common Cron Patterns
- "0 9 * * *" - Daily at 9:00 AM
- "0 8 * * 1" - Every Monday at 8:00 AM
- "*/15 * * * *" - Every 15 minutes
- "0 0 1 * *" - First day of month at midnight
### Example Trigger Actions
CRITICAL: When creating triggers, follow the EXACT format shown below with comments above the JSON:
- Put "action", "config_type", and "name" as comments (starting with //) ABOVE the JSON
- The JSON should contain "change_description" and "config_changes"
- Always use "action: create_new" for new triggers
One-time trigger example (COPY THIS EXACT FORMAT):
// action: create_new
// config_type: one_time_trigger
// name: Weekly Report - Dec 15
{
"change_description": "Create a one-time trigger to generate weekly report on December 15th at 2 PM",
"config_changes": {
"scheduledTime": "2024-12-15T14:00:00Z",
"input": {
"messages": [{"role": "user", "content": "Generate the weekly performance report"}]
}
}
}
Recurring trigger example (COPY THIS EXACT FORMAT):
// action: create_new
// config_type: recurring_trigger
// name: Daily Status Check
{
"change_description": "Create a recurring trigger to check system status every morning at 9 AM",
"config_changes": {
"cron": "0 9 * * *",
"input": {
"messages": [{"role": "user", "content": "Check system status and alert if any issues found"}]
}
}
}
### Editing and Deleting Triggers
You can also edit or delete existing triggers that are shown in the current workflow context.
Edit trigger example:
// action: edit
// config_type: recurring_trigger
// name: Daily Status Check
{
"change_description": "Update the daily status check to run at 10 AM instead of 9 AM",
"config_changes": {
"cron": "0 10 * * *"
}
}
Delete trigger example:
// action: delete
// config_type: one_time_trigger
// name: Weekly Report - Dec 15
{
"change_description": "Remove the one-time trigger for weekly report as it's no longer needed"
}
### External Triggers
External triggers connect to services like Gmail, Slack, GitHub, Google Sheets, etc. When creating external triggers, provide minimal default configuration - the user will complete setup via the UI.
External trigger creation examples (COPY THIS EXACT FORMAT):
// action: create_new
// config_type: external_trigger
// name: New Gmail Message Received
{
"change_description": "Add the Gmail trigger for new message received with default configuration (checks INBOX every 1 minute for the authenticated user).",
"config_changes": {
"triggerTypeSlug": "GMAIL_NEW_GMAIL_MESSAGE",
"toolkitSlug": "gmail",
"triggerConfig": {
"interval": 1,
"labelIds": "INBOX",
"query": "",
"userId": "me"
}
}
}
// action: create_new
// config_type: external_trigger
// name: New Rows in Google Sheet
{
"change_description": "Add the Google Sheets trigger to detect new rows with default configuration",
"config_changes": {
"triggerTypeSlug": "GOOGLESHEETS_NEW_ROWS_IN_GOOGLE_SHEET",
"toolkitSlug": "googlesheets",
"triggerConfig": {
"interval": 1,
"sheet_name": "Sheet1",
"start_row": 2,
"spreadsheet_id": ""
}
}
}
External trigger deletion:
// action: delete
// config_type: external_trigger
// name: Slack Message Received
{
"change_description": "Remove the Slack message trigger as we're switching to a different notification system"
}
</about_triggers>
<about_pipelines>
## Section: Creating and Managing Pipelines
@ -260,4 +453,4 @@ Below are details you should use when a user asks questions on how to use the pr
{USING_ROWBOAT_DOCS}
</general_guidelines>
`;
`;

View file

@ -0,0 +1,70 @@
const RANGE_SEPARATOR = "-";
const STEP_SEPARATOR = "/";
export function isValidCronExpression(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return false;
}
const [minute, hour, day, month, dayOfWeek] = parts;
const validatePart = (part: string, max: number): boolean => {
if (part === "*") {
return true;
}
if (part.includes(STEP_SEPARATOR)) {
const [range, step] = part.split(STEP_SEPARATOR);
if (!step) {
return false;
}
const stepValue = Number(step);
if (!Number.isInteger(stepValue) || stepValue <= 0) {
return false;
}
if (range === "*") {
return stepValue <= max;
}
return validatePart(range, max);
}
if (part.includes(RANGE_SEPARATOR)) {
const [start, end] = part.split(RANGE_SEPARATOR);
if (start === undefined || end === undefined) {
return false;
}
const startValue = Number(start);
const endValue = Number(end);
if (!Number.isInteger(startValue) || !Number.isInteger(endValue)) {
return false;
}
if (startValue > endValue) {
return false;
}
return startValue >= 0 && endValue <= max;
}
const value = Number(part);
if (!Number.isInteger(value)) {
return false;
}
return value >= 0 && value <= max;
};
return (
validatePart(minute, 59) &&
validatePart(hour, 23) &&
validatePart(day, 31) &&
validatePart(month, 12) &&
validatePart(dayOfWeek, 7)
);
}

View file

@ -17,6 +17,15 @@ export const ListedRecurringRuleItem = RecurringJobRule.omit({
input: true,
});
/**
* Schema for updating a recurring job rule.
*/
export const UpdateRecurringRuleSchema = RecurringJobRule
.pick({
input: true,
cron: true,
});
/**
* Repository interface for managing recurring job rules in the system.
*
@ -82,6 +91,16 @@ export interface IRecurringJobRulesRepository {
*/
toggle(id: string, disabled: boolean): Promise<z.infer<typeof RecurringJobRule>>;
/**
* Updates a recurring job rule with new input and cron expression.
*
* @param id - The unique identifier of the recurring job rule to update
* @param data - The update data containing input messages and cron expression
* @returns Promise resolving to the updated recurring job rule
* @throws {NotFoundError} if the recurring job rule doesn't exist
*/
update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>>;
/**
* Deletes a recurring job rule by its unique identifier.
*

View file

@ -24,6 +24,17 @@ export const UpdateJobSchema = ScheduledJobRule.pick({
output: true,
});
/**
* Schema for updating a scheduled job rule's next run configuration.
*/
export const UpdateScheduledRuleSchema = ScheduledJobRule
.pick({
input: true,
})
.extend({
scheduledTime: z.string().datetime(),
});
/**
* Repository interface for managing scheduled job rules in the system.
*
@ -69,6 +80,16 @@ export interface IScheduledJobRulesRepository {
*/
update(id: string, data: z.infer<typeof UpdateJobSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
/**
* Updates a scheduled job rule with new input and scheduled time.
*
* @param id - The unique identifier of the scheduled job rule to update
* @param data - The update data containing input messages and scheduled time
* @returns Promise resolving to the updated scheduled job rule
* @throws {NotFoundError} if the scheduled job rule doesn't exist
*/
updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
/**
* Releases a scheduled job rule after it has been executed.
*
@ -103,4 +124,4 @@ export interface IScheduledJobRulesRepository {
* @returns Promise resolving to void
*/
deleteByProjectId(projectId: string): Promise<void>;
}
}

View file

@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
import { ICacheService } from '@/src/application/services/cache.service.interface';
import { IUsageQuotaPolicy } from '@/src/application/policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '@/src/application/policies/project-action-authorization.policy';
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from '@/src/entities/models/copilot';
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from '@/src/entities/models/copilot';
import { Workflow } from '@/app/lib/types/workflow_types';
import { USE_BILLING } from "@/app/lib/feature_flags";
import { authorize, getCustomerIdForProject } from "@/app/lib/billing";
@ -19,6 +19,7 @@ const inputSchema = z.object({
workflow: Workflow,
context: CopilotChatContext.nullable(),
dataSources: z.array(DataSourceSchemaForCopilot).optional(),
triggers: z.array(TriggerSchemaForCopilot).optional(),
}),
});

View file

@ -90,6 +90,7 @@ export class RunCopilotCachedTurnUseCase implements IRunCopilotCachedTurnUseCase
cachedTurn.messages,
cachedTurn.workflow,
cachedTurn.dataSources || [],
cachedTurn.triggers || [],
)) {
yield event;
}

View file

@ -5,6 +5,7 @@ import { IProjectActionAuthorizationPolicy } from '../../policies/project-action
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
import { Message } from '@/app/lib/types/types';
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
@ -42,7 +43,7 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
// Validate cron expression
if (!this.isValidCronExpression(request.cron)) {
if (!isValidCronExpression(request.cron)) {
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
}
@ -66,31 +67,4 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
return rule;
}
private isValidCronExpression(cron: string): boolean {
const parts = cron.split(' ');
if (parts.length !== 5) {
return false;
}
// Basic validation - in production you'd want more sophisticated validation
const [minute, hour, day, month, dayOfWeek] = parts;
// Check if parts are valid
const isValidPart = (part: string) => {
if (part === '*') return true;
if (part.includes('/')) {
const [range, step] = part.split('/');
if (range === '*' || (parseInt(step) > 0 && parseInt(step) <= 59)) return true;
return false;
}
if (part.includes('-')) {
const [start, end] = part.split('-');
return !isNaN(parseInt(start)) && !isNaN(parseInt(end)) && parseInt(start) <= parseInt(end);
}
return !isNaN(parseInt(part));
};
return isValidPart(minute) && isValidPart(hour) && isValidPart(day) && isValidPart(month) && isValidPart(dayOfWeek);
}
}

View file

@ -0,0 +1,69 @@
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
import { Message } from '@/app/lib/types/types';
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
cron: z.string(),
});
export interface IUpdateRecurringJobRuleUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
}
export class UpdateRecurringJobRuleUseCase implements IUpdateRecurringJobRuleUseCase {
private readonly recurringJobRulesRepository: IRecurringJobRulesRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
recurringJobRulesRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
recurringJobRulesRepository: IRecurringJobRulesRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.recurringJobRulesRepository = recurringJobRulesRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
if (!isValidCronExpression(request.cron)) {
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
}
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId: request.projectId,
});
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
const rule = await this.recurringJobRulesRepository.fetch(request.ruleId);
if (!rule || rule.projectId !== request.projectId) {
throw new NotFoundError('Recurring job rule not found');
}
return await this.recurringJobRulesRepository.update(request.ruleId, {
input: request.input,
cron: request.cron,
});
}
}

View file

@ -0,0 +1,64 @@
import { NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IScheduledJobRulesRepository } from '../../repositories/scheduled-job-rules.repository.interface';
import { ScheduledJobRule } from '@/src/entities/models/scheduled-job-rule';
import { Message } from '@/app/lib/types/types';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
scheduledTime: z.string().datetime(),
});
export interface IUpdateScheduledJobRuleUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
}
export class UpdateScheduledJobRuleUseCase implements IUpdateScheduledJobRuleUseCase {
private readonly scheduledJobRulesRepository: IScheduledJobRulesRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
scheduledJobRulesRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
scheduledJobRulesRepository: IScheduledJobRulesRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.scheduledJobRulesRepository = scheduledJobRulesRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId: request.projectId,
});
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId);
if (!rule || rule.projectId !== request.projectId) {
throw new NotFoundError('Scheduled job rule not found');
}
return await this.scheduledJobRulesRepository.updateRule(request.ruleId, {
input: request.input,
scheduledTime: request.scheduledTime,
});
}
}

View file

@ -2,6 +2,9 @@ import { z } from "zod";
import { Workflow } from "@/app/lib/types/workflow_types";
import { Message } from "@/app/lib/types/types";
import { DataSource } from "@/src/entities/models/data-source";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
export const DataSourceSchemaForCopilot = DataSource.pick({
id: true,
@ -10,6 +13,43 @@ export const DataSourceSchemaForCopilot = DataSource.pick({
data: true,
});
export const ScheduledJobRuleSchemaForCopilot = ScheduledJobRule.pick({
id: true,
nextRunAt: true,
status: true,
input: true,
}).extend({
type: z.literal('one_time'),
name: z.string(),
});
export const RecurringJobRuleSchemaForCopilot = RecurringJobRule.pick({
id: true,
cron: true,
nextRunAt: true,
disabled: true,
input: true,
}).extend({
type: z.literal('recurring'),
name: z.string(),
});
export const ComposioTriggerDeploymentSchemaForCopilot = ComposioTriggerDeployment.pick({
id: true,
triggerTypeName: true,
toolkitSlug: true,
triggerTypeSlug: true,
triggerConfig: true,
}).extend({
type: z.literal('external'),
});
export const TriggerSchemaForCopilot = z.union([
ScheduledJobRuleSchemaForCopilot,
RecurringJobRuleSchemaForCopilot,
ComposioTriggerDeploymentSchemaForCopilot,
]);
export const CopilotUserMessage = z.object({
role: z.literal('user'),
content: z.string(),
@ -21,7 +61,7 @@ export const CopilotAssistantMessageTextPart = z.object({
export const CopilotAssistantMessageActionPart = z.object({
type: z.literal("action"),
content: z.object({
config_type: z.enum(['tool', 'agent', 'prompt', 'pipeline', 'start_agent']),
config_type: z.enum(['tool', 'agent', 'prompt', 'pipeline', 'start_agent', 'one_time_trigger', 'recurring_trigger', 'external_trigger']),
action: z.enum(['create_new', 'edit', 'delete']),
name: z.string(),
change_description: z.string(),
@ -60,6 +100,7 @@ export const CopilotAPIRequest = z.object({
workflow: Workflow,
context: CopilotChatContext.nullable(),
dataSources: z.array(DataSourceSchemaForCopilot).optional(),
triggers: z.array(TriggerSchemaForCopilot).optional(),
});
export const CopilotAPIResponse = z.union([
z.object({

View file

@ -1,7 +1,7 @@
import { z } from "zod";
import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb";
import { CreateRecurringRuleSchema, IRecurringJobRulesRepository, ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface";
import { CreateRecurringRuleSchema, IRecurringJobRulesRepository, ListedRecurringRuleItem, UpdateRecurringRuleSchema } from "@/src/application/repositories/recurring-job-rules.repository.interface";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list";
@ -208,6 +208,31 @@ export class MongoDBRecurringJobRulesRepository implements IRecurringJobRulesRep
return await this.updateNextRunAt(id, result.cron);
}
/**
* Updates a recurring job rule with new input and schedule.
*/
async update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>> {
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
input: data.input,
cron: data.cron,
updatedAt: now,
},
},
{ returnDocument: "after" },
);
if (!result) {
throw new NotFoundError(`Recurring job rule ${id} not found`);
}
return await this.updateNextRunAt(id, data.cron);
}
/**
* Deletes a recurring job rule by its unique identifier.
*/

View file

@ -1,7 +1,7 @@
import { z } from "zod";
import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb";
import { CreateRuleSchema, IScheduledJobRulesRepository, ListedRuleItem, UpdateJobSchema } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
import { CreateRuleSchema, IScheduledJobRulesRepository, ListedRuleItem, UpdateJobSchema, UpdateScheduledRuleSchema } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list";
@ -138,6 +138,41 @@ export class MongoDBScheduledJobRulesRepository implements IScheduledJobRulesRep
return this.convertDocToModel(result);
}
/**
* Reconfigures a scheduled job rule's input and next run time.
*/
async updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
const scheduledDate = new Date(data.scheduledTime);
const nextRunAtSeconds = Math.floor(scheduledDate.getTime() / 1000);
const nextRunAt = Math.floor(nextRunAtSeconds / 60) * 60;
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
input: data.input,
nextRunAt,
status: "pending",
workerId: null,
lastWorkerId: null,
updatedAt: now,
},
$unset: {
output: "",
processedAt: "",
},
},
{ returnDocument: "after" },
);
if (!result) {
throw new NotFoundError(`Scheduled job rule ${id} not found`);
}
return this.convertDocToModel(result);
}
/**
* Updates a scheduled job rule with new status and output data.
*/

View file

@ -1,5 +1,5 @@
import { z } from "zod";
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from '@/src/entities/models/copilot';
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from '@/src/entities/models/copilot';
import { Workflow } from '@/app/lib/types/workflow_types';
import { ICreateCopilotCachedTurnUseCase } from "@/src/application/use-cases/copilot/create-copilot-cached-turn.use-case";
import { BadRequestError } from "@/src/entities/errors/common";
@ -14,6 +14,7 @@ const inputSchema = z.object({
workflow: Workflow,
context: CopilotChatContext.nullable(),
dataSources: z.array(DataSourceSchemaForCopilot).optional(),
triggers: z.array(TriggerSchemaForCopilot).optional(),
}),
});

View file

@ -0,0 +1,50 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IUpdateRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(z.any()),
}),
cron: z.string(),
});
export interface IUpdateRecurringJobRuleController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
}
export class UpdateRecurringJobRuleController implements IUpdateRecurringJobRuleController {
private readonly updateRecurringJobRuleUseCase: IUpdateRecurringJobRuleUseCase;
constructor({
updateRecurringJobRuleUseCase,
}: {
updateRecurringJobRuleUseCase: IUpdateRecurringJobRuleUseCase,
}) {
this.updateRecurringJobRuleUseCase = updateRecurringJobRuleUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, ruleId, input, cron } = result.data;
return await this.updateRecurringJobRuleUseCase.execute({
caller,
userId,
apiKey,
projectId,
ruleId,
input,
cron,
});
}
}

View file

@ -0,0 +1,51 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IUpdateScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { Message } from "@/app/lib/types/types";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
scheduledTime: z.string().datetime(),
});
export interface IUpdateScheduledJobRuleController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
}
export class UpdateScheduledJobRuleController implements IUpdateScheduledJobRuleController {
private readonly updateScheduledJobRuleUseCase: IUpdateScheduledJobRuleUseCase;
constructor({
updateScheduledJobRuleUseCase,
}: {
updateScheduledJobRuleUseCase: IUpdateScheduledJobRuleUseCase,
}) {
this.updateScheduledJobRuleUseCase = updateScheduledJobRuleUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, ruleId, input, scheduledTime } = result.data;
return await this.updateScheduledJobRuleUseCase.execute({
caller,
userId,
apiKey,
projectId,
ruleId,
input,
scheduledTime,
});
}
}