add composio triggers (#192)

This commit is contained in:
Ramnique Singh 2025-08-08 02:27:42 +05:30 committed by GitHub
parent 5e706f0684
commit 3552302f4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 4887 additions and 111 deletions

View file

@ -22,6 +22,19 @@ import {
import { ComposioConnectedAccount } from "@/app/lib/types/project_types";
import { getProjectConfig, projectAuthCheck } from "./project_actions";
import { projectsCollection } from "../lib/mongodb";
import { container } from "@/di/container";
import { ICreateComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller";
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { IDeleteComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller";
import { IListComposioTriggerTypesController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller";
import { IDeleteComposioConnectedAccountController } from "@/src/interface-adapters/controllers/composio/delete-composio-connected-account.controller";
import { authCheck } from "./auth_actions";
const createComposioTriggerDeploymentController = container.resolve<ICreateComposioTriggerDeploymentController>("createComposioTriggerDeploymentController");
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>("listComposioTriggerDeploymentsController");
const deleteComposioTriggerDeploymentController = container.resolve<IDeleteComposioTriggerDeploymentController>("deleteComposioTriggerDeploymentController");
const listComposioTriggerTypesController = container.resolve<IListComposioTriggerTypesController>("listComposioTriggerTypesController");
const deleteComposioConnectedAccountController = container.resolve<IDeleteComposioConnectedAccountController>("deleteComposioConnectedAccountController");
const ZCreateCustomConnectedAccountRequest = z.object({
toolkitSlug: z.string(),
@ -191,29 +204,77 @@ export async function syncConnectedAccount(projectId: string, toolkitSlug: strin
}
export async function deleteConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise<boolean> {
await projectAuthCheck(projectId);
const user = await authCheck();
// ensure that the connected account belongs to this project
const project = await getProjectConfig(projectId);
const account = project.composioConnectedAccounts?.[toolkitSlug];
if (!account || account.id !== connectedAccountId) {
throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId} for toolkit ${toolkitSlug}`);
}
// delete the connected account
await libDeleteConnectedAccount(connectedAccountId);
// get auth config data
const authConfig = await libGetAuthConfig(account.authConfigId);
// delete the auth config if it is NOT managed by composio
if (!authConfig.is_composio_managed) {
await libDeleteAuthConfig(account.authConfigId);
}
// update project with deleted connected account
const key = `composioConnectedAccounts.${toolkitSlug}`;
await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } });
await deleteComposioConnectedAccountController.execute({
caller: 'user',
userId: user._id,
projectId,
toolkitSlug,
connectedAccountId,
});
return true;
}
export async function listComposioTriggerTypes(toolkitSlug: string, cursor?: string) {
await authCheck();
return await listComposioTriggerTypesController.execute({
toolkitSlug,
cursor,
});
}
export async function createComposioTriggerDeployment(request: {
projectId: string,
toolkitSlug: string,
triggerTypeSlug: string,
connectedAccountId: string,
triggerConfig?: Record<string, unknown>,
}) {
const user = await authCheck();
// create trigger deployment
return await createComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
data: {
projectId: request.projectId,
toolkitSlug: request.toolkitSlug,
triggerTypeSlug: request.triggerTypeSlug,
connectedAccountId: request.connectedAccountId,
triggerConfig: request.triggerConfig ?? {},
},
});
}
export async function listComposioTriggerDeployments(request: {
projectId: string,
cursor?: string,
}) {
const user = await authCheck();
// list trigger deployments
return await listComposioTriggerDeploymentsController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
});
}
export async function deleteComposioTriggerDeployment(request: {
projectId: string,
deploymentId: string,
}) {
const user = await authCheck();
// delete trigger deployment
return await deleteComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
deploymentId: request.deploymentId,
});
}

View file

@ -0,0 +1,37 @@
"use server";
import { container } from "@/di/container";
import { IListConversationsController } from "@/src/interface-adapters/controllers/conversations/list-conversations.controller";
import { IFetchConversationController } from "@/src/interface-adapters/controllers/conversations/fetch-conversation.controller";
import { authCheck } from "./auth_actions";
const listConversationsController = container.resolve<IListConversationsController>('listConversationsController');
const fetchConversationController = container.resolve<IFetchConversationController>('fetchConversationController');
export async function listConversations(request: {
projectId: string,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
return await listConversationsController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
});
}
export async function fetchConversation(request: {
conversationId: string,
}) {
const user = await authCheck();
return await fetchConversationController.execute({
caller: 'user',
userId: user._id,
conversationId: request.conversationId,
});
}

View file

@ -0,0 +1,37 @@
"use server";
import { container } from "@/di/container";
import { IListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
import { IFetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
import { authCheck } from "./auth_actions";
const listJobsController = container.resolve<IListJobsController>('listJobsController');
const fetchJobController = container.resolve<IFetchJobController>('fetchJobController');
export async function listJobs(request: {
projectId: string,
cursor?: string,
limit?: number,
}) {
const user = await authCheck();
return await listJobsController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
});
}
export async function fetchJob(request: {
jobId: string,
}) {
const user = await authCheck();
return await fetchJobController.execute({
caller: 'user',
userId: user._id,
jobId: request.jobId,
});
}

View file

@ -0,0 +1,69 @@
import { PrefixLogger } from "@/app/lib/utils";
import { container } from "@/di/container";
import { IHandleComposioWebhookRequestController } from "@/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller";
import { nanoid } from "nanoid";
const handleComposioWebhookRequestController = container.resolve<IHandleComposioWebhookRequestController>("handleComposioWebhookRequestController");
export async function POST(request: Request) {
const id = nanoid();
const logger = new PrefixLogger(`composio-webhook-[${id}]`);
const payload = await request.text();
const headers = Object.fromEntries(request.headers.entries());
logger.log('received event', JSON.stringify(headers));
// handle webhook
try {
await handleComposioWebhookRequestController.execute({
headers,
payload,
});
} catch (error) {
logger.log('Error handling composio webhook', error);
}
return Response.json({
success: true,
});
}
/*
{
"type": "slack_receive_message",
"timestamp": "2025-08-06T01:49:46.008Z",
"data": {
"bot_id": null,
"channel": "C08PTQKM2DS",
"channel_type": "channel",
"team_id": null,
"text": "test",
"ts": "1754444983.699449",
"user": "U077XPW36V9",
"connection_id": "551d86b3-44e3-4c62-b996-44648ccf77b3",
"connection_nano_id": "ca_2n0cZnluJ1qc",
"trigger_nano_id": "ti_dU7LJMfP5KSr",
"trigger_id": "ec96b753-c745-4f37-b5d8-82a35ce0fa0b",
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
}
}
{
"type": "github_issue_added_event",
"timestamp": "2025-08-06T02:00:13.680Z",
"data": {
"action": "opened",
"createdAt": "2025-08-06T02:00:10Z",
"createdBy": "ramnique",
"description": "this is a test issue",
"issue_id": 3294929549,
"number": 1,
"title": "test issue",
"url": "https://github.com/ramnique/stack-reload-bug/issues/1",
"connection_id": "06d7c6b9-bd41-4ce7-a6b4-b17a65315c99",
"connection_nano_id": "ca_HmQ-SSOdxUEu",
"trigger_nano_id": "ti_IjLPi4O0d4xo",
"trigger_id": "ccbf3ad3-442b-491c-a1c5-e23f8b606592",
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
}
}
*/

View file

@ -6,7 +6,7 @@ import { createOpenAI } from "@ai-sdk/openai";
import { CoreMessage, embed, generateText } from "ai";
import { ObjectId } from "mongodb";
import { z } from "zod";
import { Composio } from '@composio/core';
import { composio } from "./composio/composio";
import { SignJWT } from "jose";
import crypto from "crypto";
@ -311,8 +311,6 @@ async function invokeComposioTool(
}
}
const composio = new Composio();
const result = await composio.tools.execute(slug, {
userId: projectId,
arguments: input,

View file

@ -0,0 +1,138 @@
'use client';
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import Link from "next/link";
function ToolCallDisplay({ toolCall }: { toolCall: any }) {
return (
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
TOOL CALL: {toolCall.function.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
ID: {toolCall.id}
</span>
</div>
<div className="text-xs text-gray-700 dark:text-gray-300 font-mono">
<div className="mb-1">
<span className="font-semibold">Arguments:</span>
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700">
{toolCall.function.arguments}
</pre>
</div>
</div>
);
}
export function MessageDisplay({ message, index }: { message: z.infer<typeof Message>; index: number }) {
const isUser = 'role' in message && message.role === 'user';
const isAssistant = 'role' in message && message.role === 'assistant';
const isSystem = 'role' in message && message.role === 'system';
const isTool = 'role' in message && message.role === 'tool';
// Check if assistant message is internal
const isInternal = isAssistant && 'responseType' in message && message.responseType === 'internal';
const getBubbleStyle = () => {
if (isUser) {
return 'ml-auto max-w-[80%] bg-blue-100 text-blue-900 border border-blue-200 rounded-2xl rounded-br-md';
} else if (isAssistant) {
if (isInternal) {
return 'mr-auto max-w-[80%] bg-gray-50 text-gray-700 border border-dotted border-gray-300 rounded-2xl rounded-bl-md';
} else {
return 'mr-auto max-w-[80%] bg-green-100 text-green-900 border border-green-200 rounded-2xl rounded-bl-md';
}
} else if (isSystem) {
return 'mx-auto max-w-[90%] bg-yellow-100 text-yellow-900 border border-yellow-200 rounded-2xl';
} else if (isTool) {
return 'mr-auto max-w-[80%] bg-purple-100 text-purple-900 border border-purple-200 rounded-2xl rounded-bl-md';
}
return 'mx-auto max-w-[80%] bg-gray-100 text-gray-900 border border-gray-200 rounded-2xl';
};
const getRoleLabel = () => {
if ('role' in message) {
switch (message.role) {
case 'user':
return 'USER';
case 'assistant':
const baseLabel = 'agentName' in message && message.agentName ? `ASSISTANT (${message.agentName})` : 'ASSISTANT';
return isInternal ? `${baseLabel} [INTERNAL]` : baseLabel;
case 'system':
return 'SYSTEM';
case 'tool':
return 'toolName' in message ? `TOOL (${message.toolName})` : 'TOOL';
default:
return (message as any).role?.toUpperCase() || 'UNKNOWN';
}
}
return 'UNKNOWN';
};
const getMessageContent = () => {
if ('content' in message && message.content) {
return message.content;
}
return '[No content]';
};
const getTimestamp = () => {
if ('timestamp' in message && message.timestamp) {
return new Date(message.timestamp).toLocaleTimeString();
}
return null;
};
const timestamp = getTimestamp();
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
<div className={`${getBubbleStyle()} p-3 shadow-sm`}>
{/* Message Header */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold opacity-90">
{getRoleLabel()}
</span>
<div className="flex items-center gap-2">
{timestamp && (
<span className="text-xs opacity-75">
{timestamp}
</span>
)}
<span className="text-xs opacity-75">
#{index + 1}
</span>
</div>
</div>
{/* Message Content */}
<div className="text-sm">
{isTool ? (
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono whitespace-pre-wrap">
{getMessageContent()}
</pre>
) : (
<div className="whitespace-pre-wrap">
{getMessageContent()}
</div>
)}
</div>
{/* Tool Calls Display */}
{isAssistant && 'toolCalls' in message && message.toolCalls && message.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs font-semibold opacity-90 border-t border-current/20 pt-2">
TOOL CALLS ({message.toolCalls.length})
</div>
{message.toolCalls.map((toolCall, toolIndex) => (
<ToolCallDisplay key={toolCall.id || toolIndex} toolCall={toolCall} />
))}
</div>
)}
</div>
</div>
);
}

View file

@ -1,8 +1,12 @@
import { z } from "zod";
import { PrefixLogger } from "../utils";
import { Composio } from "@composio/core";
const BASE_URL = 'https://backend.composio.dev/api/v3';
const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || "";
const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || "test";
export const composio = new Composio({
apiKey: COMPOSIO_API_KEY,
});
export const ZAuthScheme = z.enum([
'API_KEY',
@ -27,14 +31,17 @@ export const ZConnectedAccountStatus = z.enum([
'INACTIVE',
]);
const ZToolkitMeta = z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
triggers_count: z.number(),
});
export const ZToolkit = z.object({
slug: z.string(),
name: z.string(),
meta: z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
}),
meta: ZToolkitMeta,
no_auth: z.boolean(),
auth_schemes: z.array(ZAuthScheme),
composio_managed_auth_schemes: z.array(ZAuthScheme),
@ -53,6 +60,7 @@ export const ZGetToolkitResponse = z.object({
slug: z.string(),
name: z.string(),
composio_managed_auth_schemes: z.array(ZAuthScheme),
meta: ZToolkitMeta,
auth_config_details: z.array(z.object({
name: z.string(),
mode: ZAuthScheme,
@ -217,6 +225,23 @@ export const ZDeleteOperationResponse = z.object({
success: z.boolean(),
});
export const ZTriggerType = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}),
config: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
title: z.string().optional(),
}),
});
export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
items: z.array(schema),
next_cursor: z.string().nullable(),
@ -415,4 +440,17 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
return await composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
export async function listTriggersTypes(toolkitSlug: string, cursor?: string): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTriggerType>>>> {
const url = new URL(`${BASE_URL}/triggers_types`);
// set params
url.searchParams.set("toolkit_slugs", toolkitSlug);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
// fetch
return composioApiCall(ZListResponse(ZTriggerType), url.toString());
}

View file

@ -11,9 +11,8 @@ import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent";
import { COPILOT_INSTRUCTIONS_MULTI_AGENT } from "./copilot_multi_agent";
import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1";
import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow";
import { Composio } from '@composio/core';
import { USE_COMPOSIO_TOOLS } from "../feature_flags";
import { getTool } from "../composio/composio";
import { composio, getTool } from "../composio/composio";
const PROVIDER_API_KEY = process.env.PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
const PROVIDER_BASE_URL = process.env.PROVIDER_BASE_URL || undefined;
@ -103,8 +102,6 @@ async function searchRelevantTools(query: string): Promise<string> {
return 'No tools found!';
}
const composio = new Composio();
// Search for relevant tool slugs
logger.log('searching for relevant tools...');
const searchResult = await composio.tools.execute('COMPOSIO_SEARCH_TOOLS', {

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { ConversationView } from "../components/conversation-view";
export const metadata: Metadata = {
title: "Conversation",
};
export default async function Page(
props: {
params: Promise<{ projectId: string, conversationId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <ConversationView projectId={params.projectId} conversationId={params.conversationId} />;
}

View file

@ -0,0 +1,228 @@
'use client';
import { useEffect, useMemo, useState } from "react";
import { Spinner } from "@heroui/react";
import { Panel } from "@/components/common/panel-common";
import { fetchConversation } from "@/app/actions/conversation_actions";
import { Conversation } from "@/src/entities/models/conversation";
import { Turn } from "@/src/entities/models/turn";
import { z } from "zod";
import Link from "next/link";
import { MessageDisplay } from "../../../../lib/components/message-display";
function TurnReason({ reason }: { reason: z.infer<typeof Turn>['reason'] }) {
const getReasonDisplay = () => {
switch (reason.type) {
case 'chat':
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
case 'api':
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
case 'job':
return { label: `JOB: ${reason.jobId}`, color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' };
default:
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
}
};
const { label, color } = getReasonDisplay();
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
{label}
</span>
);
}
function TurnReasonWithLink({ reason, projectId }: { reason: z.infer<typeof Turn>['reason']; projectId: string }) {
const getReasonDisplay = () => {
switch (reason.type) {
case 'chat':
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
case 'api':
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
case 'job':
return {
label: `JOB: ${reason.jobId}`,
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
isJob: true,
jobId: reason.jobId
};
default:
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
}
};
const { label, color, isJob, jobId } = getReasonDisplay();
if (isJob && jobId) {
return (
<Link
href={`/projects/${projectId}/jobs/${jobId}`}
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color} hover:opacity-80 transition-opacity`}
>
{label}
</Link>
);
}
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
{label}
</span>
);
}
function TurnContainer({ turn, index, projectId }: { turn: z.infer<typeof Turn>; index: number; projectId: string }) {
return (
<div id={`turn-${turn.id}`} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Turn Header */}
<div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-mono font-semibold text-gray-700 dark:text-gray-300">
TURN #{index + 1}
</span>
<TurnReasonWithLink reason={turn.reason} projectId={projectId} />
</div>
<div className="text-xs text-gray-500 dark:text-gray-500">
{new Date(turn.createdAt).toLocaleTimeString()}
</div>
</div>
</div>
{/* Turn Content */}
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Input Messages */}
{turn.input.messages && turn.input.messages.length > 0 && (
<div className="p-4 bg-gray-50 dark:bg-gray-900/50">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
Input Messages ({turn.input.messages.length})
</div>
<div className="space-y-1">
{turn.input.messages.map((message, msgIndex) => (
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
))}
</div>
</div>
)}
{/* Output Messages */}
{turn.output && turn.output.length > 0 && (
<div className="p-4">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
Output Messages ({turn.output.length})
</div>
<div className="space-y-1">
{turn.output.map((message, msgIndex) => (
<MessageDisplay key={`output-${msgIndex}`} message={message} index={msgIndex} />
))}
</div>
</div>
)}
{/* Error Display */}
{turn.error && (
<div className="p-4 bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500">
<div className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1 uppercase tracking-wide">
Error
</div>
<div className="text-sm text-red-700 dark:text-red-300 font-mono">
{turn.error}
</div>
</div>
)}
</div>
</div>
);
}
export function ConversationView({ projectId, conversationId }: { projectId: string; conversationId: string; }) {
const [conversation, setConversation] = useState<z.infer<typeof Conversation> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchConversation({ conversationId });
if (ignore) return;
setConversation(res);
setLoading(false);
})();
return () => { ignore = true; };
}, [conversationId]);
const title = useMemo(() => {
if (!conversation) return 'Conversation';
return `Conversation ${conversation.id}`;
}, [conversation]);
return (
<Panel
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
rightActions={<div className="flex items-center gap-3"></div>}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && conversation && (
<div className="flex flex-col gap-6">
{/* Conversation 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">Conversation ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{conversation.id}</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">
{new Date(conversation.createdAt).toLocaleString()}
</span>
</div>
{conversation.updatedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{new Date(conversation.updatedAt).toLocaleString()}
</span>
</div>
)}
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Live Workflow:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{conversation.isLiveWorkflow ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
{/* Turns */}
{conversation.turns && conversation.turns.length > 0 ? (
<div className="space-y-4">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Turns ({conversation.turns.length})
</div>
{conversation.turns.map((turn, index) => (
<TurnContainer key={turn.id} turn={turn} index={index} projectId={projectId} />
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-sm font-mono">No turns in this conversation.</div>
</div>
)}
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,151 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listConversations } from "@/app/actions/conversation_actions";
import { z } from "zod";
import { ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
type ListedItem = z.infer<typeof ListedConversationItem>;
export function ConversationsList({ projectId }: { projectId: string }) {
const [items, setItems] = useState<ListedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listConversations({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
return res;
}, [projectId]);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchPage(null);
if (ignore) return;
setItems(res.items);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoading(false);
})();
return () => { ignore = true; };
}, [fetchPage]);
const loadMore = useCallback(async () => {
if (!cursor) return;
setLoadingMore(true);
const res = await fetchPage(cursor);
setItems(prev => [...prev, ...res.items]);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoadingMore(false);
}, [cursor, fetchPage]);
const sections = useMemo(() => {
const groups: Record<string, ListedItem[]> = {
Today: [],
'This week': [],
'This month': [],
Older: [],
};
for (const item of items) {
const d = new Date(item.createdAt);
if (isToday(d)) groups['Today'].push(item);
else if (isThisWeek(d)) groups['This week'].push(item);
else if (isThisMonth(d)) groups['This month'].push(item);
else groups['Older'].push(item);
}
return groups;
}, [items]);
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CONVERSATIONS
</div>
</div>
}
rightActions={
<div className="flex items-center gap-3">
{/* Reserved for future actions */}
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && items.length === 0 && (
<p className="mt-4 text-center">No conversations yet.</p>
)}
{!loading && items.length > 0 && (
<div className="flex flex-col gap-8">
{Object.entries(sections).map(([label, group]) => (
group.length > 0 ? (
<div key={label}>
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Conversation</th>
<th className="w-[30%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{group.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-6 py-4 text-left">
<Link
href={`/projects/${projectId}/conversations/${c.id}`}
size="lg"
isBlock
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{c.id}
</Link>
</td>
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
{new Date(c.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { ConversationsList } from "./components/conversations-list";
export const metadata: Metadata = {
title: "Conversations",
};
export default async function Page(
props: {
params: Promise<{ projectId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <ConversationsList projectId={params.projectId} />;
}

View file

@ -0,0 +1,17 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { JobView } from "../components/job-view";
export const metadata: Metadata = {
title: "Job",
};
export default async function Page(
props: {
params: Promise<{ projectId: string, jobId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <JobView projectId={params.projectId} jobId={params.jobId} />;
}

View file

@ -0,0 +1,234 @@
'use client';
import { useEffect, useMemo, useState } from "react";
import { Spinner } from "@heroui/react";
import { Panel } from "@/components/common/panel-common";
import { fetchJob } from "@/app/actions/job_actions";
import { Job } from "@/src/entities/models/job";
import { z } from "zod";
import Link from "next/link";
import { MessageDisplay } from "../../../../lib/components/message-display";
export function JobView({ projectId, jobId }: { projectId: string; jobId: string; }) {
const [job, setJob] = useState<z.infer<typeof Job> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchJob({ jobId });
if (ignore) return;
setJob(res);
setLoading(false);
})();
return () => { ignore = true; };
}, [jobId]);
const title = useMemo(() => {
if (!job) return 'Job';
return `Job ${job.id}`;
}, [job]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600 dark:text-green-400';
case 'failed':
return 'text-red-600 dark:text-red-400';
case 'running':
return 'text-blue-600 dark:text-blue-400';
case 'pending':
return 'text-yellow-600 dark:text-yellow-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
};
const getReasonDisplay = (reason: any) => {
if (reason.type === 'composio_trigger') {
return {
type: 'Composio Trigger',
details: {
'Trigger Type': reason.triggerTypeSlug,
'Trigger ID': reason.triggerId,
'Deployment ID': reason.triggerDeploymentId,
},
payload: reason.payload
};
}
return {
type: 'Unknown',
details: {},
payload: null
};
};
// Extract conversation and turn IDs from job output
const conversationId = job?.output?.conversationId;
const turnId = job?.output?.turnId;
const reasonInfo = job ? getReasonDisplay(job.reason) : null;
return (
<Panel
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
rightActions={<div className="flex items-center gap-3"></div>}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && job && (
<div className="flex flex-col gap-6">
{/* Job 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">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{job.id}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<span className={`ml-2 font-mono ${getStatusColor(job.status)}`}>
{job.status}
</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">
{new Date(job.createdAt).toLocaleString()}
</span>
</div>
{job.updatedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{new Date(job.updatedAt).toLocaleString()}
</span>
</div>
)}
{conversationId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation:</span>
<Link
href={`/projects/${projectId}/conversations/${conversationId}`}
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
>
{conversationId}
</Link>
</div>
)}
{turnId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Turn:</span>
<Link
href={`/projects/${projectId}/conversations/${conversationId}#turn-${turnId}`}
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
>
{turnId}
</Link>
</div>
)}
{job.output?.error && (
<div className="col-span-2">
<span className="font-semibold text-red-700 dark:text-red-300">Error:</span>
<span className="ml-2 font-mono text-red-600 dark:text-red-400">
{job.output.error}
</span>
</div>
)}
</div>
</div>
{/* Job Reason */}
{reasonInfo && (
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
Job Reason
</div>
<div className="space-y-4">
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
{reasonInfo.type}
</div>
<div className="grid grid-cols-1 gap-2 text-sm">
{Object.entries(reasonInfo.details).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="font-semibold text-gray-700 dark:text-gray-300">{key}:</span>
<span className="font-mono text-gray-600 dark:text-gray-400">{value}</span>
</div>
))}
</div>
</div>
{reasonInfo.payload && Object.keys(reasonInfo.payload).length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
Trigger Payload
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[300px]">
{JSON.stringify(reasonInfo.payload, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Job Input */}
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
Job Input
</div>
<div className="space-y-4">
{/* Messages */}
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
Messages ({job.input.messages.length})
</div>
<div className="space-y-1">
{job.input.messages.map((message, msgIndex) => (
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
))}
</div>
</div>
{/* Workflow */}
<div>
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
Workflow
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[400px]">
{JSON.stringify(job.input.workflow, null, 2)}
</pre>
</div>
</div>
</div>
{/* Job Output */}
{job.output && (
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
Job Output
</div>
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono">
{JSON.stringify(job.output, null, 2)}
</pre>
</div>
)}
</div>
)}
{!loading && !job && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-sm font-mono">Job not found.</div>
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,183 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listJobs } from "@/app/actions/job_actions";
import { z } from "zod";
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
type ListedItem = z.infer<typeof ListedJobItem>;
export function JobsList({ projectId }: { projectId: string }) {
const [items, setItems] = useState<ListedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listJobs({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
return res;
}, [projectId]);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
const res = await fetchPage(null);
if (ignore) return;
setItems(res.items);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoading(false);
})();
return () => { ignore = true; };
}, [fetchPage]);
const loadMore = useCallback(async () => {
if (!cursor) return;
setLoadingMore(true);
const res = await fetchPage(cursor);
setItems(prev => [...prev, ...res.items]);
setCursor(res.nextCursor);
setHasMore(Boolean(res.nextCursor));
setLoadingMore(false);
}, [cursor, fetchPage]);
const sections = useMemo(() => {
const groups: Record<string, ListedItem[]> = {
Today: [],
'This week': [],
'This month': [],
Older: [],
};
for (const item of items) {
const d = new Date(item.createdAt);
if (isToday(d)) groups['Today'].push(item);
else if (isThisWeek(d)) groups['This week'].push(item);
else if (isThisMonth(d)) groups['This month'].push(item);
else groups['Older'].push(item);
}
return groups;
}, [items]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600 dark:text-green-400';
case 'failed':
return 'text-red-600 dark:text-red-400';
case 'running':
return 'text-blue-600 dark:text-blue-400';
case 'pending':
return 'text-yellow-600 dark:text-yellow-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
};
const getReasonDisplay = (reason: any) => {
if (reason.type === 'composio_trigger') {
return `Composio: ${reason.triggerTypeSlug}`;
}
return 'Unknown';
};
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
JOBS
</div>
</div>
}
rightActions={
<div className="flex items-center gap-3">
{/* Reserved for future actions */}
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && items.length === 0 && (
<p className="mt-4 text-center">No jobs yet.</p>
)}
{!loading && items.length > 0 && (
<div className="flex flex-col gap-8">
{Object.entries(sections).map(([label, group]) => (
group.length > 0 ? (
<div key={label}>
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Job</th>
<th className="w-[20%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</th>
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{group.map((job) => (
<tr key={job.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-6 py-4 text-left">
<Link
href={`/projects/${projectId}/jobs/${job.id}`}
size="lg"
isBlock
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{job.id}
</Link>
</td>
<td className="px-6 py-4 text-left">
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
{job.status}
</span>
</td>
<td className="px-6 py-4 text-left">
<span className="text-sm text-gray-600 dark:text-gray-300 font-mono">
{getReasonDisplay(job.reason)}
</span>
</td>
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
{new Date(job.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</Panel>
);
}

View file

@ -0,0 +1,17 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { JobsList } from "./components/jobs-list";
export const metadata: Metadata = {
title: "Jobs",
};
export default async function Page(
props: {
params: Promise<{ projectId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <JobsList projectId={params.projectId} />;
}

View file

@ -10,34 +10,31 @@ import { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ComposioToolsPanel } from './ComposioToolsPanel';
import { ToolkitCard } from './ToolkitCard';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { Workflow } from '@/app/lib/types/workflow_types';
type ToolkitType = z.infer<typeof ZToolkit>;
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
type ProjectType = z.infer<typeof Project>;
interface ComposioProps {
interface SelectComposioToolkitProps {
projectId: string;
tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
onSelectToolkit: (toolkit: ToolkitType) => void;
initialToolkitSlug?: string | null;
}
export function Composio({
export function SelectComposioToolkit({
projectId,
tools,
onAddTool,
onSelectToolkit,
initialToolkitSlug
}: ComposioProps) {
}: SelectComposioToolkitProps) {
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const loadProjectConfig = useCallback(async () => {
try {
@ -84,14 +81,8 @@ export function Composio({
}, [projectId]);
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
}, []);
const handleCloseToolsPanel = useCallback(() => {
setSelectedToolkit(null);
setIsToolsPanelOpen(false);
}, []);
onSelectToolkit(toolkit);
}, [onSelectToolkit]);
useEffect(() => {
loadProjectConfig();
@ -106,11 +97,10 @@ export function Composio({
if (initialToolkitSlug && toolkits.length > 0) {
const toolkit = toolkits.find(t => t.slug === initialToolkitSlug);
if (toolkit) {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
onSelectToolkit(toolkit);
}
}
}, [initialToolkitSlug, toolkits]);
}, [initialToolkitSlug, toolkits, onSelectToolkit]);
const filteredToolkits = toolkits.filter(toolkit => {
const searchLower = searchQuery.toLowerCase();
@ -226,15 +216,6 @@ export function Composio({
</p>
</div>
)}
{/* Tools Panel */}
{selectedToolkit && <ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
tools={tools}
onAddTool={onAddTool}
/>}
</div>
);
}

View file

@ -3,10 +3,12 @@
import { useState } from 'react';
import { Tabs, Tab } from '@/components/ui/tabs';
import { CustomMcpServers } from './CustomMcpServer';
import { Composio } from './Composio';
import { SelectComposioToolkit } from './SelectComposioToolkit';
import { ComposioToolsPanel } from './ComposioToolsPanel';
import { AddWebhookTool } from './AddWebhookTool';
import type { Key } from 'react';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { ZToolkit } from '@/app/lib/composio/composio';
import { z } from 'zod';
interface ToolsConfigProps {
@ -17,6 +19,8 @@ interface ToolsConfigProps {
initialToolkitSlug?: string | null;
}
type ToolkitType = z.infer<typeof ZToolkit>;
export function ToolsConfig({
projectId,
useComposioTools,
@ -29,11 +33,28 @@ export function ToolsConfig({
defaultActiveTab = 'composio';
}
const [activeTab, setActiveTab] = useState(defaultActiveTab);
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const handleTabChange = (key: Key) => {
setActiveTab(key.toString());
};
const handleSelectToolkit = (toolkit: ToolkitType) => {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
};
const handleCloseToolsPanel = () => {
setSelectedToolkit(null);
setIsToolsPanelOpen(false);
};
const handleAddTool = (tool: z.infer<typeof WorkflowTool>) => {
onAddTool(tool);
handleCloseToolsPanel();
};
return (
<div className="h-full flex flex-col">
<Tabs
@ -46,10 +67,10 @@ export function ToolsConfig({
{useComposioTools && (
<Tab key="composio" title="Composio">
<div className="mt-4 p-6">
<Composio
<SelectComposioToolkit
projectId={projectId}
tools={tools}
onAddTool={onAddTool}
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={initialToolkitSlug}
/>
</div>
@ -72,6 +93,17 @@ export function ToolsConfig({
</div>
</Tab>
</Tabs>
{/* Tools Panel */}
{selectedToolkit && (
<ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
tools={tools}
onAddTool={handleAddTool}
/>
)}
</div>
);
}

View file

@ -0,0 +1,207 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Button, Card, CardBody, CardHeader, Spinner } from '@heroui/react';
import { ChevronLeft, ChevronRight, ZapIcon, ArrowLeft } from 'lucide-react';
import { z } from 'zod';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { listComposioTriggerTypes } from '@/app/actions/composio_actions';
import { ZToolkit } from '@/app/lib/composio/composio';
interface ComposioTriggerTypesPanelProps {
toolkit: z.infer<typeof ZToolkit>;
onBack: () => void;
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
}
type TriggerType = z.infer<typeof ComposioTriggerType>;
export function ComposioTriggerTypesPanel({
toolkit,
onBack,
onSelectTriggerType,
}: ComposioTriggerTypesPanelProps) {
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
try {
if (resetList) {
setLoading(true);
setTriggerTypes([]);
} else {
setLoadingMore(true);
}
setError(null);
const response = await listComposioTriggerTypes(toolkit.slug, nextCursor);
if (resetList) {
setTriggerTypes(response.items);
} else {
setTriggerTypes(prev => [...prev, ...response.items]);
}
setCursor(response.nextCursor);
setHasNextPage(!!response.nextCursor);
} catch (err: any) {
console.error('Error loading trigger types:', err);
setError('Failed to load trigger types. Please try again.');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [toolkit.slug]);
const handleLoadMore = () => {
if (cursor && !loadingMore) {
loadTriggerTypes(false, cursor);
}
};
const handleTriggerTypeSelect = (triggerType: TriggerType) => {
onSelectTriggerType(triggerType);
};
useEffect(() => {
loadTriggerTypes(true);
}, [loadTriggerTypes]);
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{toolkit.name} Triggers
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a trigger type to set up
</p>
</div>
</div>
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
<span className="ml-2">Loading trigger types...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{toolkit.name} Triggers
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a trigger type to set up
</p>
</div>
</div>
<div className="text-center py-12">
<p className="text-red-500 mb-4">{error}</p>
<Button variant="flat" onPress={() => loadTriggerTypes(true)}>
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{toolkit.name} Triggers
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a trigger type to set up ({triggerTypes.length} available)
</p>
</div>
</div>
{triggerTypes.length === 0 ? (
<div className="text-center py-12">
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No trigger types available
</h3>
<p className="text-gray-500 dark:text-gray-400">
This toolkit doesn&apos;t have any trigger types configured.
</p>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{triggerTypes.map((triggerType) => (
<Card
key={triggerType.slug}
className="cursor-pointer hover:shadow-md transition-shadow"
isPressable
onPress={() => handleTriggerTypeSelect(triggerType)}
>
<CardHeader className="flex gap-3">
<div className="flex items-center justify-center w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<ZapIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex flex-col">
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">
{triggerType.name}
</p>
</div>
</CardHeader>
<CardBody className="pt-0">
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{triggerType.description}
</p>
<div className="mt-3 flex justify-end">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleTriggerTypeSelect(triggerType)}
>
Configure
</Button>
</div>
</CardBody>
</Card>
))}
</div>
{hasNextPage && (
<div className="flex justify-center pt-4">
<Button
variant="flat"
onPress={handleLoadMore}
isLoading={loadingMore}
startContent={!loadingMore ? <ChevronRight className="w-4 h-4" /> : null}
>
{loadingMore ? 'Loading...' : 'Load More'}
</Button>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,263 @@
'use client';
import React, { useState, useCallback } from 'react';
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
import { z } from 'zod';
import { ZToolkit } from '@/app/lib/composio/composio';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
interface TriggerConfigFormProps {
toolkit: z.infer<typeof ZToolkit>;
triggerType: z.infer<typeof ComposioTriggerType>;
onBack: () => void;
onSubmit: (config: Record<string, unknown>) => void;
isSubmitting?: boolean;
}
interface JsonSchemaProperty {
type: string;
title?: string;
description?: string;
default?: any;
enum?: any[];
}
interface JsonSchema {
type: 'object';
properties: Record<string, JsonSchemaProperty>;
required?: string[];
title?: string;
}
export function TriggerConfigForm({
toolkit,
triggerType,
onBack,
onSubmit,
isSubmitting = false,
}: TriggerConfigFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
// Parse the JSON schema from triggerType.config
const schema = triggerType.config as JsonSchema;
const handleSubmit = useCallback(() => {
// Validate required fields
const newErrors: Record<string, string> = {};
if (schema.required) {
schema.required.forEach(fieldName => {
if (!formData[fieldName] || formData[fieldName].trim() === '') {
const field = schema.properties[fieldName];
newErrors[fieldName] = `${field?.title || fieldName} is required`;
}
});
}
setErrors(newErrors);
// If no errors, submit the form
if (Object.keys(newErrors).length === 0) {
// Convert form data to appropriate types based on schema
const processedData: Record<string, unknown> = {};
Object.entries(formData).forEach(([key, value]) => {
const property = schema.properties[key];
if (property) {
switch (property.type) {
case 'number':
case 'integer':
processedData[key] = value ? Number(value) : undefined;
break;
case 'boolean':
processedData[key] = value === 'true';
break;
default:
processedData[key] = value;
}
}
});
onSubmit(processedData);
}
}, [formData, schema, onSubmit]);
const handleFieldChange = useCallback((fieldName: string, value: string) => {
setFormData(prev => ({ ...prev, [fieldName]: value }));
// Clear error for this field if it exists
if (errors[fieldName]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
}
}, [errors]);
// Check if trigger requires configuration
const hasConfigFields = schema && schema.properties && Object.keys(schema.properties).length > 0;
if (!hasConfigFields) {
// No configuration needed - show success state
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{triggerType.name} Configuration
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
No additional configuration required
</p>
</div>
</div>
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<ZapIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div className="absolute -top-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
<CheckCircleIcon className="w-4 h-4 text-white" />
</div>
</div>
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
Ready to Create Trigger!
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This trigger type doesn&apos;t require additional configuration. You can create it directly.
</p>
<Button
color="primary"
size="lg"
onPress={() => onSubmit({})}
isLoading={isSubmitting}
>
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="light" isIconOnly onPress={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Configure {triggerType.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{triggerType.description}
</p>
</div>
</div>
<Card>
<CardHeader>
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
Trigger Configuration
</h4>
</CardHeader>
<CardBody>
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Configure the settings for your {toolkit.name} trigger:
</div>
<div className="space-y-4">
{Object.entries(schema.properties).map(([fieldName, property]) => {
const isRequired = schema.required?.includes(fieldName) || false;
const fieldValue = formData[fieldName] || '';
const fieldError = errors[fieldName];
// Handle different input types based on property type
if (property.enum) {
// Render select for enum fields
return (
<div key={fieldName}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{property.title || fieldName}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<select
value={fieldValue}
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
required={isRequired}
>
<option value="">Select {property.title || fieldName}</option>
{property.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{property.description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{property.description}
</p>
)}
{fieldError && (
<p className="mt-1 text-xs text-red-500">{fieldError}</p>
)}
</div>
);
}
return (
<Input
key={fieldName}
label={property.title || fieldName}
placeholder={property.description || `Enter ${property.title || fieldName}`}
value={fieldValue}
onValueChange={(value) => handleFieldChange(fieldName, value)}
isRequired={isRequired}
type={property.type === 'number' || property.type === 'integer' ? 'number' : 'text'}
variant="bordered"
description={property.description}
isInvalid={!!fieldError}
errorMessage={fieldError}
/>
);
})}
</div>
</div>
</CardBody>
</Card>
<div className="flex justify-end gap-3">
<Button
variant="bordered"
onPress={onBack}
isDisabled={isSubmitting}
>
Back
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={isSubmitting}
>
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,359 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Spinner, Card, CardBody, CardHeader } from '@heroui/react';
import { Plus, Trash2, ZapIcon } from 'lucide-react';
import { z } from 'zod';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { listComposioTriggerDeployments, deleteComposioTriggerDeployment, createComposioTriggerDeployment } from '@/app/actions/composio_actions';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from './ComposioTriggerTypesPanel';
import { TriggerConfigForm } from './TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { ZToolkit } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
interface TriggersModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
projectConfig: z.infer<typeof Project>;
onProjectConfigUpdated?: () => void;
}
type TriggerDeployment = z.infer<typeof ComposioTriggerDeployment>;
export function TriggersModal({
isOpen,
onClose,
projectId,
projectConfig,
onProjectConfigUpdated,
}: TriggersModalProps) {
const [triggers, setTriggers] = useState<TriggerDeployment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateFlow, setShowCreateFlow] = useState(false);
const [selectedToolkit, setSelectedToolkit] = useState<z.infer<typeof ZToolkit> | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<z.infer<typeof ComposioTriggerType> | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
const loadTriggers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await listComposioTriggerDeployments({ projectId });
setTriggers(response.items);
} catch (err: any) {
console.error('Error loading triggers:', err);
setError('Failed to load triggers. Please try again.');
} finally {
setLoading(false);
}
}, [projectId]);
const handleDeleteTrigger = async (deploymentId: string) => {
if (!window.confirm('Are you sure you want to delete this trigger?')) {
return;
}
try {
setDeletingTrigger(deploymentId);
await deleteComposioTriggerDeployment({ projectId, deploymentId });
await loadTriggers(); // Reload the list
} catch (err: any) {
console.error('Error deleting trigger:', err);
setError('Failed to delete trigger. Please try again.');
} finally {
setDeletingTrigger(null);
}
};
const handleCreateNew = () => {
setShowCreateFlow(true);
};
const handleBackToList = () => {
setShowCreateFlow(false);
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setIsSubmittingTrigger(false);
loadTriggers(); // Reload in case any triggers were created
};
const handleSelectToolkit = (toolkit: z.infer<typeof ZToolkit>) => {
setSelectedToolkit(toolkit);
};
const handleBackToToolkitSelection = () => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setIsSubmittingTrigger(false);
};
const handleSelectTriggerType = (triggerType: z.infer<typeof ComposioTriggerType>) => {
if (!selectedToolkit) return;
setSelectedTriggerType(triggerType);
// Check if toolkit requires auth and if connected account exists
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (needsAuth && !hasConnection) {
// Show auth modal
setShowAuthModal(true);
} else {
// Proceed to trigger configuration
// For now this is just the placeholder, but will be actual config later
}
};
const handleAuthComplete = async () => {
setShowAuthModal(false);
onProjectConfigUpdated?.();
};
const handleTriggerSubmit = async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) return;
try {
setIsSubmittingTrigger(true);
// Get the connected account ID for this toolkit
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
throw new Error('No connected account found for this toolkit');
}
// Create the trigger deployment
await createComposioTriggerDeployment({
projectId,
toolkitSlug: selectedToolkit.slug,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
// Success! Go back to triggers list and reload
handleBackToList();
} catch (err: any) {
console.error('Error creating trigger:', err);
setError('Failed to create trigger. Please try again.');
} finally {
setIsSubmittingTrigger(false);
}
};
useEffect(() => {
if (isOpen && !showCreateFlow) {
loadTriggers();
}
}, [isOpen, showCreateFlow, loadTriggers]);
const renderTriggerList = () => {
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
<span className="ml-2">Loading triggers...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<p className="text-red-500 mb-4">{error}</p>
<Button variant="flat" onPress={loadTriggers}>
Try Again
</Button>
</div>
);
}
if (triggers.length === 0) {
return (
<div className="text-center py-12">
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No triggers configured
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">
Set up your first trigger to listen for events from your connected apps.
</p>
<Button
color="primary"
variant="solid"
startContent={<Plus className="w-4 h-4" />}
onPress={handleCreateNew}
>
Create your first trigger
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Active Triggers ({triggers.length})
</h3>
<Button
color="primary"
variant="solid"
startContent={<Plus className="w-4 h-4" />}
onPress={handleCreateNew}
>
Create New Trigger
</Button>
</div>
<div className="space-y-3">
{triggers.map((trigger) => (
<Card key={trigger.id} className="w-full">
<CardHeader className="flex justify-between items-start">
<div>
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
{trigger.triggerTypeSlug}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Created {new Date(trigger.createdAt).toLocaleDateString()}
</p>
</div>
<Button
isIconOnly
variant="light"
color="danger"
size="sm"
isLoading={deletingTrigger === trigger.id}
onPress={() => handleDeleteTrigger(trigger.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</CardHeader>
<CardBody className="pt-0">
<div className="text-sm text-gray-600 dark:text-gray-300">
<p><strong>Trigger ID:</strong> {trigger.triggerId}</p>
<p><strong>Connected Account:</strong> {trigger.connectedAccountId}</p>
{Object.keys(trigger.triggerConfig).length > 0 && (
<div className="mt-2">
<strong>Configuration:</strong>
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded">
{JSON.stringify(trigger.triggerConfig, null, 2)}
</pre>
</div>
)}
</div>
</CardBody>
</Card>
))}
</div>
</div>
);
};
const renderCreateFlow = () => {
// If trigger type is selected and auth is complete, show config
if (selectedToolkit && selectedTriggerType && !showAuthModal) {
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (!needsAuth || hasConnection) {
return (
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={handleBackToToolkitSelection}
onSubmit={handleTriggerSubmit}
isSubmitting={isSubmittingTrigger}
/>
);
}
}
// If no toolkit selected, show toolkit selection
if (!selectedToolkit) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Select a Toolkit to Create Trigger
</h3>
<Button
variant="flat"
onPress={handleBackToList}
>
Back to Triggers
</Button>
</div>
<SelectComposioToolkit
projectId={projectId}
tools={[]} // Empty array since we're not using this for tools
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={null}
/>
</div>
);
}
// If toolkit selected, show trigger types
return (
<div className="space-y-4">
<ComposioTriggerTypesPanel
toolkit={selectedToolkit}
onBack={handleBackToToolkitSelection}
onSelectTriggerType={handleSelectTriggerType}
/>
</div>
);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="5xl"
scrollBehavior="inside"
>
<ModalContent className="max-h-[90vh]">
<ModalHeader>
<div className="flex items-center gap-2">
<ZapIcon className="w-5 h-5" />
<span>Manage Triggers</span>
</div>
</ModalHeader>
<ModalBody>
{showCreateFlow ? renderCreateFlow() : renderTriggerList()}
</ModalBody>
{!showCreateFlow && (
<ModalFooter>
<Button variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
{/* Auth Modal */}
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={selectedToolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -26,7 +26,7 @@ import { publishWorkflow } from "@/app/actions/project_actions";
import { saveWorkflow } from "@/app/actions/project_actions";
import { updateProjectName } from "@/app/actions/project_actions";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon, ZapIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
@ -37,6 +37,7 @@ import { ConfigApp } from "../config/app";
import { InputField } from "@/app/lib/components/input-field";
import { VoiceSection } from "../config/components/voice";
import { ChatWidgetSection } from "../config/components/project";
import { TriggersModal } from "./components/TriggersModal";
enablePatches();
@ -882,6 +883,9 @@ export function WorkflowEditor({
// Modal state for chat widget configuration
const { isOpen: isChatWidgetModalOpen, onOpen: onChatWidgetModalOpen, onClose: onChatWidgetModalClose } = useDisclosure();
// Modal state for triggers management
const { isOpen: isTriggersModalOpen, onOpen: onTriggersModalOpen, onClose: onTriggersModalClose } = useDisclosure();
// Project name state
const [localProjectName, setLocalProjectName] = useState<string>(projectConfig.name || '');
const [projectNameError, setProjectNameError] = useState<string | null>(null);
@ -1359,6 +1363,13 @@ export function WorkflowEditor({
>
Chat widget
</DropdownItem>
<DropdownItem
key="manage-triggers"
startContent={<ZapIcon size={16} />}
onPress={onTriggersModalOpen}
>
Manage triggers
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
@ -1647,6 +1658,15 @@ export function WorkflowEditor({
</ModalBody>
</ModalContent>
</Modal>
{/* Triggers Management Modal */}
<TriggersModal
isOpen={isTriggersModalOpen}
onClose={onTriggersModalClose}
projectId={projectId}
projectConfig={projectConfig}
onProjectConfigUpdated={onProjectConfigUpdated}
/>
</div>
</EntitySelectionContext.Provider>
);

View file

@ -13,7 +13,9 @@ import {
ChevronRightIcon,
Moon,
Sun,
HelpCircle
HelpCircle,
MessageSquareIcon,
LogsIcon
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { useTheme } from "@/app/providers/theme-provider";
@ -60,6 +62,18 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
icon: WorkflowIcon,
requiresProject: true
},
{
href: 'conversations',
label: 'Conversations',
icon: MessageSquareIcon,
requiresProject: true
},
{
href: 'jobs',
label: 'Jobs',
icon: LogsIcon,
requiresProject: true
},
{
href: 'config',
label: 'Settings',

View file

@ -0,0 +1,12 @@
import '../lib/loadenv';
import { container } from "@/di/container";
import { IJobsWorker } from "@/src/application/workers/jobs.worker";
(async () => {
try {
const jobsWorker = container.resolve<IJobsWorker>('jobsWorker');
await jobsWorker.run();
} catch (error) {
console.error(`Unable to run jobs worker: ${error}`);
}
})();

View file

@ -13,6 +13,31 @@ import { RedisUsageQuotaPolicy } from "@/src/infrastructure/policies/redis.usage
import { ProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
import { MongoDBProjectMembersRepository } from "@/src/infrastructure/repositories/mongodb.project-members.repository";
import { MongoDBApiKeysRepository } from "@/src/infrastructure/repositories/mongodb.api-keys.repository";
import { MongodbProjectsRepository } from "@/src/infrastructure/repositories/mongodb.projects.repository";
import { MongodbComposioTriggerDeploymentsRepository } from "@/src/infrastructure/repositories/mongodb.composio-trigger-deployments.repository";
import { CreateComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case";
import { ListComposioTriggerDeploymentsUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case";
import { DeleteComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case";
import { ListComposioTriggerTypesUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case";
import { DeleteComposioConnectedAccountUseCase } from "@/src/application/use-cases/composio/delete-composio-connected-account.use-case";
import { HandleCompsioWebhookRequestUseCase } from "@/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case";
import { MongoDBJobsRepository } from "@/src/infrastructure/repositories/mongodb.jobs.repository";
import { CreateComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller";
import { DeleteComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller";
import { ListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { ListComposioTriggerTypesController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller";
import { DeleteComposioConnectedAccountController } from "@/src/interface-adapters/controllers/composio/delete-composio-connected-account.controller";
import { HandleComposioWebhookRequestController } from "@/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller";
import { RedisPubSubService } from "@/src/infrastructure/services/redis.pub-sub.service";
import { JobsWorker } from "@/src/application/workers/jobs.worker";
import { ListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
import { ListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
import { ListConversationsUseCase } from "@/src/application/use-cases/conversations/list-conversations.use-case";
import { ListConversationsController } from "@/src/interface-adapters/controllers/conversations/list-conversations.controller";
import { FetchJobUseCase } from "@/src/application/use-cases/jobs/fetch-job.use-case";
import { FetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
import { FetchConversationUseCase } from "@/src/application/use-cases/conversations/fetch-conversation.use-case";
import { FetchConversationController } from "@/src/interface-adapters/controllers/conversations/fetch-conversation.controller";
export const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -20,15 +45,24 @@ export const container = createContainer({
});
container.register({
// workers
// ---
jobsWorker: asClass(JobsWorker).singleton(),
// services
// ---
cacheService: asClass(RedisCacheService).singleton(),
pubSubService: asClass(RedisPubSubService).singleton(),
// policies
// ---
usageQuotaPolicy: asClass(RedisUsageQuotaPolicy).singleton(),
projectActionAuthorizationPolicy: asClass(ProjectActionAuthorizationPolicy).singleton(),
// projects
// ---
projectsRepository: asClass(MongodbProjectsRepository).singleton(),
// project members
// ---
projectMembersRepository: asClass(MongoDBProjectMembersRepository).singleton(),
@ -37,17 +71,46 @@ container.register({
// ---
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(),
// jobs
// ---
jobsRepository: asClass(MongoDBJobsRepository).singleton(),
listJobsUseCase: asClass(ListJobsUseCase).singleton(),
listJobsController: asClass(ListJobsController).singleton(),
fetchJobUseCase: asClass(FetchJobUseCase).singleton(),
fetchJobController: asClass(FetchJobController).singleton(),
// composio
// ---
deleteComposioConnectedAccountUseCase: asClass(DeleteComposioConnectedAccountUseCase).singleton(),
handleCompsioWebhookRequestUseCase: asClass(HandleCompsioWebhookRequestUseCase).singleton(),
deleteComposioConnectedAccountController: asClass(DeleteComposioConnectedAccountController).singleton(),
handleComposioWebhookRequestController: asClass(HandleComposioWebhookRequestController).singleton(),
// composio trigger deployments
// ---
composioTriggerDeploymentsRepository: asClass(MongodbComposioTriggerDeploymentsRepository).singleton(),
listComposioTriggerTypesUseCase: asClass(ListComposioTriggerTypesUseCase).singleton(),
createComposioTriggerDeploymentUseCase: asClass(CreateComposioTriggerDeploymentUseCase).singleton(),
listComposioTriggerDeploymentsUseCase: asClass(ListComposioTriggerDeploymentsUseCase).singleton(),
deleteComposioTriggerDeploymentUseCase: asClass(DeleteComposioTriggerDeploymentUseCase).singleton(),
createComposioTriggerDeploymentController: asClass(CreateComposioTriggerDeploymentController).singleton(),
deleteComposioTriggerDeploymentController: asClass(DeleteComposioTriggerDeploymentController).singleton(),
listComposioTriggerDeploymentsController: asClass(ListComposioTriggerDeploymentsController).singleton(),
listComposioTriggerTypesController: asClass(ListComposioTriggerTypesController).singleton(),
// conversations
// ---
conversationsRepository: asClass(MongoDBConversationsRepository).singleton(),
createConversationUseCase: asClass(CreateConversationUseCase).singleton(),
createCachedTurnUseCase: asClass(CreateCachedTurnUseCase).singleton(),
fetchCachedTurnUseCase: asClass(FetchCachedTurnUseCase).singleton(),
runConversationTurnUseCase: asClass(RunConversationTurnUseCase).singleton(),
listConversationsUseCase: asClass(ListConversationsUseCase).singleton(),
fetchConversationUseCase: asClass(FetchConversationUseCase).singleton(),
createPlaygroundConversationController: asClass(CreatePlaygroundConversationController).singleton(),
createCachedTurnController: asClass(CreateCachedTurnController).singleton(),
runCachedTurnController: asClass(RunCachedTurnController).singleton(),
runTurnController: asClass(RunTurnController).singleton(),
listConversationsController: asClass(ListConversationsController).singleton(),
fetchConversationController: asClass(FetchConversationController).singleton(),
});

View file

@ -60,6 +60,7 @@
"remark-gfm": "^4.0.1",
"rowboat-shared": "github:rowboatlabs/shared",
"sharp": "^0.33.4",
"standardwebhooks": "^1.0.0",
"styled-components": "^5.3.11",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.5",
@ -1447,18 +1448,18 @@
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="
},
"node_modules/@composio/client": {
"version": "0.1.0-alpha.30",
"resolved": "https://registry.npmjs.org/@composio/client/-/client-0.1.0-alpha.30.tgz",
"integrity": "sha512-Vx8jrNdbNCY7072gW46HykR2Llr00XIwMF+Ys/A69+DViHGWeTpI5zlH9fycjmU3E1GnKpbIbhsF+gP41F2m+Q==",
"version": "0.1.0-alpha.31",
"resolved": "https://registry.npmjs.org/@composio/client/-/client-0.1.0-alpha.31.tgz",
"integrity": "sha512-DVPVCMDXzoQn1aUZx38e8s5+gA/J/c6FpVfS1rzUnMKCRSuXXrOoKXFAvoRME5pcaKN2Wjux5ARyvIPBugNoMA==",
"license": "Apache-2.0"
},
"node_modules/@composio/core": {
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/@composio/core/-/core-0.1.40.tgz",
"integrity": "sha512-OdUM2qy8wWSnWEZ25dD9esW/FV2I6TWCG7hVOpTVUDRt6bsgXh3r+3OnZSgtwhDcnQp1lBNPwSmcxSHyF6gCbA==",
"version": "0.1.41",
"resolved": "https://registry.npmjs.org/@composio/core/-/core-0.1.41.tgz",
"integrity": "sha512-wodFzWduAZ+7i08exCRDj5/0uDrQbNNrTA36EdBZE6T6+gzxH9GMnZmGXcg9WvU8dQSx/hWiJAyjp1IF4gGMtA==",
"license": "ISC",
"dependencies": {
"@composio/client": "0.1.0-alpha.30",
"@composio/client": "0.1.0-alpha.31",
"@composio/json-schema-to-zod": "0.1.11",
"@types/json-schema": "^7.0.15",
"chalk": "^4.1.2",
@ -7266,6 +7267,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@styled-system/background": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz",
@ -11391,6 +11398,12 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-xml-parser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz",
@ -16846,6 +16859,16 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View file

@ -13,7 +13,7 @@
"ragUrlsWorker": "tsx app/scripts/rag_urls_worker.ts",
"ragFilesWorker": "tsx app/scripts/rag_files_worker.ts",
"ragTextWorker": "tsx app/scripts/rag_text_worker.ts",
"worker": "tsx app/scripts/worker.ts"
"jobs-worker": "tsx app/scripts/jobs-worker.ts"
},
"dependencies": {
"@ai-sdk/openai": "^1.3.21",
@ -68,6 +68,7 @@
"remark-gfm": "^4.0.1",
"rowboat-shared": "github:rowboatlabs/shared",
"sharp": "^0.33.4",
"standardwebhooks": "^1.0.0",
"styled-components": "^5.3.11",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.5",

View file

@ -0,0 +1,93 @@
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
import { PaginatedList } from "@/src/entities/common/paginated-list";
import { z } from "zod";
/**
* Schema for creating a new Composio trigger deployment.
* Includes only the required fields for deployment creation.
*/
export const CreateDeploymentSchema = ComposioTriggerDeployment
.pick({
projectId: true,
triggerId: true,
connectedAccountId: true,
toolkitSlug: true,
logo: true,
triggerTypeSlug: true,
triggerConfig: true,
});
/**
* Repository interface for managing Composio trigger deployments.
*
* This interface defines the contract for operations related to Composio trigger deployments,
* including creating, deleting, and querying deployments by various criteria.
*
* Composio trigger deployments represent the connection between a project's trigger
* and a connected account, enabling automated workflows based on external events.
*/
export interface IComposioTriggerDeploymentsRepository {
/**
* Creates a new Composio trigger deployment.
*
* @param data - The deployment data containing projectId, triggerId, connectedAccountId, and triggerTypeSlug
* @returns Promise resolving to the created deployment with full details including id, timestamps, and disabled status
*/
create(data: z.infer<typeof CreateDeploymentSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
/**
* Fetches a trigger deployment by its ID.
*
* @param id - The unique identifier of the deployment to fetch
* @returns Promise resolving to the deployment if found, null if not found
*/
fetch(id: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null>;
/**
* Deletes a Composio trigger deployment by its ID.
*
* @param id - The unique identifier of the deployment to delete
* @returns Promise resolving to true if the deployment was deleted, false if not found
*/
delete(id: string): Promise<boolean>;
/**
* Fetches a trigger deployment by its trigger type slug and connected account ID.
*
* @param triggerTypeSlug - The slug identifier of the trigger type
* @param connectedAccountId - The unique identifier of the connected account
* @returns Promise resolving to the deployment if found, null if not found
*/
fetchBySlugAndConnectedAccountId(triggerTypeSlug: string, connectedAccountId: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null>;
/**
* Retrieves all trigger deployments for a specific project.
*
* @param projectId - The unique identifier of the project
* @param cursor - Optional cursor for pagination
* @param limit - Optional limit for the number of items to return
* @returns Promise resolving to a paginated list of deployments associated with the project
*/
listByProjectId(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
/**
* Retrieves all trigger deployments for a specific trigger.
*
* @param triggerId - The identifier of the trigger
* @param cursor - Optional cursor for pagination
* @param limit - Optional limit for the number of items to return
* @returns Promise resolving to a paginated list of deployments for the specified trigger
*/
listByTriggerId(triggerId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
/**
* Deletes all trigger deployments associated with a specific connected account.
*
* This method is typically used when a connected account is disconnected
* or when cleaning up deployments for a specific integration.
*
* @param connectedAccountId - The unique identifier of the connected account
* @returns Promise resolving to the number of records deleted
*/
deleteByConnectedAccountId(connectedAccountId: string): Promise<number>;
}

View file

@ -1,6 +1,7 @@
import { z } from "zod";
import { Conversation } from "@/src/entities/models/conversation";
import { Turn } from "@/src/entities/models/turn";
import { PaginatedList } from "@/src/entities/common/paginated-list";
export const CreateConversationData = Conversation.pick({
projectId: true,
@ -14,12 +15,22 @@ export const AddTurnData = Turn.omit({
updatedAt: true,
});
export const ListedConversationItem = Conversation.pick({
id: true,
projectId: true,
createdAt: true,
updatedAt: true,
});
export interface IConversationsRepository {
// create a new conversation
createConversation(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>>;
create(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>>;
// get conversation
getConversation(id: string): Promise<z.infer<typeof Conversation> | null>;
fetch(id: string): Promise<z.infer<typeof Conversation> | null>;
// list conversations for project
list(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>>;
// add turn data to conversation
// returns the created turn

View file

@ -0,0 +1,113 @@
import { Job } from "@/src/entities/models/job";
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
import { NotFoundError } from "@/src/entities/errors/common";
import { z } from "zod";
import { PaginatedList } from "@/src/entities/common/paginated-list";
/**
* Schema for creating a new job.
* Defines the required fields when creating a job in the system.
*/
const createJobSchema = Job.pick({
reason: true,
projectId: true,
input: true,
});
export const ListedJobItem = Job.pick({
id: true,
projectId: true,
status: true,
reason: true,
createdAt: true,
updatedAt: true,
});
/**
* Schema for updating an existing job.
* Defines the fields that can be updated for a job.
*/
const updateJobSchema = Job.pick({
status: true,
output: true,
});
/**
* Repository interface for managing jobs in the system.
*
* This interface defines the contract for job management operations including
* creation, polling, locking, updating, and releasing jobs. Jobs represent
* asynchronous tasks that can be processed by workers.
*/
export interface IJobsRepository {
/**
* Creates a new job in the system.
*
* @param data - The job data containing trigger information, project ID, and input
* @returns Promise resolving to the created job with all fields populated
*/
create(data: z.infer<typeof createJobSchema>): Promise<z.infer<typeof Job>>;
/**
* Fetches a job by its unique identifier.
*
* @param id - The unique identifier of the job to fetch
* @returns Promise resolving to the job or null if not found
*/
fetch(id: string): Promise<z.infer<typeof Job> | null>;
/**
* Polls for the next available job that can be processed by a worker.
*
* This method should return the next job that is in "pending" status and
* is not currently locked by another worker.
*
* @param workerId - The unique identifier of the worker requesting a job
* @returns Promise resolving to the next available job or null if no jobs are available
*/
poll(workerId: string): Promise<z.infer<typeof Job> | null>;
/**
* Locks a specific job for processing by a worker.
*
* This method should mark the job as "running" and associate it with the
* specified worker ID to prevent other workers from processing it.
*
* @param id - The unique identifier of the job to lock
* @param workerId - The unique identifier of the worker locking the job
* @returns Promise resolving to the locked job
* @throws {JobAcquisitionError} if the job is already locked or doesn't exist
*/
lock(id: string, workerId: string): Promise<z.infer<typeof Job>>;
/**
* Updates an existing job with new status and/or output data.
*
* @param id - The unique identifier of the job to update
* @param data - The data to update (status and/or output)
* @returns Promise resolving to the updated job
* @throws {NotFoundError} if the job doesn't exist
*/
update(id: string, data: z.infer<typeof updateJobSchema>): Promise<z.infer<typeof Job>>;
/**
* Releases a job lock, making it available for other workers.
*
* This method should clear the workerId association and potentially
* reset the status back to "pending" if the job was not completed.
*
* @param id - The unique identifier of the job to release
* @returns Promise that resolves when the job has been released
*/
release(id: string): Promise<void>;
/**
* Lists jobs for a specific project with pagination.
*
* @param projectId - The unique identifier of the project
* @param cursor - Optional cursor for pagination
* @param limit - Maximum number of jobs to return (default: 50)
* @returns Promise resolving to a paginated list of jobs
*/
list(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
}

View file

@ -0,0 +1,8 @@
import { z } from "zod";
import { Project } from "@/src/entities/models/project";
export interface IProjectsRepository {
fetch(id: string): Promise<z.infer<typeof Project> | null>;
deleteComposioConnectedAccount(projectId: string, toolkitSlug: string): Promise<boolean>;
}

View file

@ -0,0 +1,115 @@
/**
* Represents a subscription to a pub-sub channel.
*
* This interface provides a way to manage subscriptions to pub-sub channels,
* allowing subscribers to unsubscribe from channels when they no longer need
* to receive messages.
*/
export interface Subscription {
/**
* Unsubscribes from the associated pub-sub channel.
*
* This method should be called when the subscriber no longer wants to
* receive messages from the channel. After calling this method, the
* handler function will no longer be invoked for new messages on the channel.
*
* @returns A promise that resolves when the unsubscribe operation is complete
* @throws {Error} If the unsubscribe operation fails
*
* @example
* ```typescript
* const subscription = await pubSubService.subscribe('user-events', (message) => {
* console.log('Received message:', message);
* });
*
* // Later, when you want to stop receiving messages
* await subscription.unsubscribe();
* ```
*/
unsubscribe(): Promise<void>;
}
/**
* Interface for a publish-subscribe (pub-sub) service.
*
* This interface defines the contract for a pub-sub service that allows
* publishing messages to channels and subscribing to receive messages from
* those channels. It provides a decoupled communication pattern where
* publishers and subscribers don't need to know about each other directly.
*
* The service supports:
* - Publishing messages to specific channels
* - Subscribing to channels to receive messages
* - Managing subscriptions with the ability to unsubscribe
*
* @example
* ```typescript
* // Publishing a message
* await pubSubService.publish('user-events', JSON.stringify({
* userId: '123',
* action: 'login',
* timestamp: new Date().toISOString()
* }));
*
* // Subscribing to receive messages
* const subscription = await pubSubService.subscribe('user-events', (message) => {
* const event = JSON.parse(message);
* console.log(`User ${event.userId} performed ${event.action}`);
* });
*
* // Unsubscribing when done
* await subscription.unsubscribe();
* ```
*/
export interface IPubSubService {
/**
* Publishes a message to a specific channel.
*
* This method sends a message to all subscribers of the specified channel.
* The message is delivered asynchronously to all active subscribers.
*
* @param channel - The channel name to publish the message to
* @param message - The message content to publish (typically a JSON string)
* @returns A promise that resolves when the message has been published
* @throws {Error} If the publish operation fails (e.g., network error, invalid channel)
*
* @example
* ```typescript
* await pubSubService.publish('notifications', JSON.stringify({
* type: 'alert',
* message: 'System maintenance scheduled',
* priority: 'high'
* }));
* ```
*/
publish(channel: string, message: string): Promise<void>;
/**
* Subscribes to a channel to receive messages.
*
* This method creates a subscription to the specified channel. When a message
* is published to the channel, the provided handler function will be invoked
* with the message content.
*
* The subscription remains active until the returned subscription object's
* `unsubscribe()` method is called.
*
* @param channel - The channel name to subscribe to
* @param handler - A function that will be called when messages are received on the channel.
* The function receives the message content as a string parameter.
* @returns A promise that resolves to a Subscription object that can be used to unsubscribe
* @throws {Error} If the subscribe operation fails (e.g., network error, invalid channel)
*
* @example
* ```typescript
* const subscription = await pubSubService.subscribe('chat-room-123', (message) => {
* const chatMessage = JSON.parse(message);
* console.log(`${chatMessage.user}: ${chatMessage.text}`);
* });
*
* // Store the subscription for later cleanup
* this.subscriptions.push(subscription);
* ```
*/
subscribe(channel: string, handler: (message: string) => void): Promise<Subscription>;
}

View file

@ -0,0 +1,100 @@
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 { CreateDeploymentSchema, IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
import { IProjectsRepository } from '../../repositories/projects.repository.interface';
import { composio, getToolkit } from '../../../../app/lib/composio/composio';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
data: CreateDeploymentSchema.omit({
triggerId: true,
logo: true,
}),
});
export interface ICreateComposioTriggerDeploymentUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
}
export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTriggerDeploymentUseCase {
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
private readonly projectsRepository: IProjectsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
composioTriggerDeploymentsRepository,
projectsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
projectsRepository: IProjectsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
this.projectsRepository = projectsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
// extract projectid from conversation
const { projectId } = request.data;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// get toolkit info
const toolkit = await getToolkit(request.data.toolkitSlug);
// ensure that connected account exists on project
const project = await this.projectsRepository.fetch(projectId);
if (!project) {
throw new NotFoundError('Project not found');
}
// ensure connected account exists
const account = project.composioConnectedAccounts?.[request.data.toolkitSlug];
if (!account || account.id !== request.data.connectedAccountId) {
throw new BadRequestError('Invalid connected account');
}
// ensure that a trigger deployment does not exist for this trigger type and connected account
const existingDeployment = await this.composioTriggerDeploymentsRepository.fetchBySlugAndConnectedAccountId(request.data.triggerTypeSlug, request.data.connectedAccountId);
if (existingDeployment) {
throw new BadRequestError('Trigger deployment already exists');
}
// create trigger on composio
const result = await composio.triggers.create(request.data.projectId, request.data.triggerTypeSlug, {
connectedAccountId: request.data.connectedAccountId,
triggerConfig: request.data.triggerConfig,
});
// create trigger deployment in db
return await this.composioTriggerDeploymentsRepository.create({
projectId,
toolkitSlug: request.data.toolkitSlug,
logo: toolkit.meta.logo,
triggerId: result.triggerId,
connectedAccountId: request.data.connectedAccountId,
triggerTypeSlug: request.data.triggerTypeSlug,
triggerConfig: request.data.triggerConfig,
});
}
}

View file

@ -0,0 +1,71 @@
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 { IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
import { IProjectsRepository } from '../../repositories/projects.repository.interface';
import { composio } from '../../../../app/lib/composio/composio';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
deploymentId: z.string(),
});
export interface IDeleteComposioTriggerDeploymentUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<boolean>;
}
export class DeleteComposioTriggerDeploymentUseCase implements IDeleteComposioTriggerDeploymentUseCase {
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
private readonly projectsRepository: IProjectsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
composioTriggerDeploymentsRepository,
projectsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
projectsRepository: IProjectsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
this.projectsRepository = projectsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
// extract projectid from conversation
const { projectId } = request;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// ensure deployment belongs to this project
const deployment = await this.composioTriggerDeploymentsRepository.fetch(request.deploymentId);
if (!deployment || deployment.projectId !== projectId) {
throw new NotFoundError('Deployment not found');
}
// delete trigger from composio
await composio.triggers.delete(deployment.triggerId);
// delete deployment
return await this.composioTriggerDeploymentsRepository.delete(request.deploymentId);
}
}

View file

@ -0,0 +1,59 @@
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 { IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
import { PaginatedList } from '@/src/entities/common/paginated-list';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
export interface IListComposioTriggerDeploymentsUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
}
export class ListComposioTriggerDeploymentsUseCase implements IListComposioTriggerDeploymentsUseCase {
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
composioTriggerDeploymentsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
// extract projectid from conversation
const { projectId, limit } = request;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// fetch deployments for project
return await this.composioTriggerDeploymentsRepository.listByProjectId(projectId, request.cursor, limit);
}
}

View file

@ -0,0 +1,26 @@
import { z } from "zod";
import { listTriggersTypes } from '../../../../app/lib/composio/composio';
import { PaginatedList } from '@/src/entities/common/paginated-list';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
const inputSchema = z.object({
toolkitSlug: z.string(),
cursor: z.string().optional(),
});
export interface IListComposioTriggerTypesUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>>;
}
export class ListComposioTriggerTypesUseCase implements IListComposioTriggerTypesUseCase {
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>> {
// call composio api to fetch trigger types
const result = await listTriggersTypes(request.toolkitSlug, request.cursor);
// return paginated list of trigger types
return {
items: result.items,
nextCursor: result.next_cursor,
};
}
}

View file

@ -0,0 +1,98 @@
import { z } from "zod";
import { IProjectsRepository } from "../../repositories/projects.repository.interface";
import { IProjectActionAuthorizationPolicy } from "../../policies/project-action-authorization.policy";
import { IUsageQuotaPolicy } from "../../policies/usage-quota.policy.interface";
import { IComposioTriggerDeploymentsRepository } from "../../repositories/composio-trigger-deployments.repository.interface";
import { BadRequestError, NotFoundError } from "@/src/entities/errors/common";
import { deleteConnectedAccount } from "../../../../app/lib/composio/composio";
import { getAuthConfig } from "../../../../app/lib/composio/composio";
import { deleteAuthConfig } from "../../../../app/lib/composio/composio";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
toolkitSlug: z.string(),
connectedAccountId: z.string(),
});
export interface IDeleteComposioConnectedAccountUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<void>;
}
export class DeleteComposioConnectedAccountUseCase implements IDeleteComposioConnectedAccountUseCase {
private readonly projectsRepository: IProjectsRepository;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
constructor({
projectsRepository,
projectActionAuthorizationPolicy,
usageQuotaPolicy,
composioTriggerDeploymentsRepository,
}: {
projectsRepository: IProjectsRepository,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
usageQuotaPolicy: IUsageQuotaPolicy,
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
}) {
this.projectsRepository = projectsRepository;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
this.usageQuotaPolicy = usageQuotaPolicy;
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
}
async execute(request: z.infer<typeof inputSchema>): Promise<void> {
// extract projectid from conversation
const { projectId } = request;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// fetch project
const project = await this.projectsRepository.fetch(projectId);
if (!project) {
throw new NotFoundError('Project not found');
}
// ensure connected account exists
const account = project.composioConnectedAccounts?.[request.toolkitSlug];
if (!account || account.id !== request.connectedAccountId) {
throw new BadRequestError('Invalid connected account');
}
// delete the connected account from composio
// this will also delete any trigger instances associated with the connected account
const result = await deleteConnectedAccount(request.connectedAccountId);
if (!result.success) {
throw new Error(`Failed to delete connected account ${request.connectedAccountId}`);
}
// delete trigger deployments data from db
await this.composioTriggerDeploymentsRepository.deleteByConnectedAccountId(request.connectedAccountId);
// get auth config data
const authConfig = await getAuthConfig(account.authConfigId);
// delete the auth config if it is NOT managed by composio
if (!authConfig.is_composio_managed) {
const result = await deleteAuthConfig(account.authConfigId);
if (!result.success) {
throw new Error(`Failed to delete auth config ${account.authConfigId}`);
}
}
// delete connected account from project
await this.projectsRepository.deleteComposioConnectedAccount(projectId, request.toolkitSlug);
}
}

View file

@ -0,0 +1,151 @@
import { IJobsRepository } from "@/src/application/repositories/jobs.repository.interface";
import { IComposioTriggerDeploymentsRepository } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
import { Webhook } from "standardwebhooks";
import { z } from "zod";
import { BadRequestError } from "@/src/entities/errors/common";
import { UserMessage } from "@/app/lib/types/types";
import { PrefixLogger } from "@/app/lib/utils";
import { IProjectsRepository } from "@/src/application/repositories/projects.repository.interface";
import { IPubSubService } from "@/src/application/services/pub-sub.service.interface";
const WEBHOOK_SECRET = process.env.COMPOSIO_TRIGGERS_WEBHOOK_SECRET || "test";
/*
{
"type": "slack_receive_message",
"timestamp": "2025-08-06T01:49:46.008Z",
"data": {
"bot_id": null,
"channel": "C08PTQKM2DS",
"channel_type": "channel",
"team_id": null,
"text": "test",
"ts": "1754444983.699449",
"user": "U077XPW36V9",
"connection_id": "551d86b3-44e3-4c62-b996-44648ccf77b3",
"connection_nano_id": "ca_2n0cZnluJ1qc",
"trigger_nano_id": "ti_dU7LJMfP5KSr",
"trigger_id": "ec96b753-c745-4f37-b5d8-82a35ce0fa0b",
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
}
}
*/
const requestSchema = z.object({
headers: z.record(z.string(), z.string()),
payload: z.string(),
});
const payloadSchema = z.object({
type: z.string(),
timestamp: z.string().datetime(),
data: z.object({
trigger_nano_id: z.string(),
}).passthrough(),
});
export interface IHandleCompsioWebhookRequestUseCase {
execute(request: z.infer<typeof requestSchema>): Promise<void>;
}
export class HandleCompsioWebhookRequestUseCase implements IHandleCompsioWebhookRequestUseCase {
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
private readonly jobsRepository: IJobsRepository;
private readonly projectsRepository: IProjectsRepository;
private readonly pubSubService: IPubSubService;
private webhook;
constructor({
composioTriggerDeploymentsRepository,
jobsRepository,
projectsRepository,
pubSubService,
}: {
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
jobsRepository: IJobsRepository;
projectsRepository: IProjectsRepository;
pubSubService: IPubSubService;
}) {
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
this.jobsRepository = jobsRepository;
this.projectsRepository = projectsRepository;
this.pubSubService = pubSubService;
this.webhook = new Webhook(WEBHOOK_SECRET);
}
async execute(request: z.infer<typeof requestSchema>): Promise<void> {
const { headers, payload } = request;
// verify payload
// try {
// this.webhook.verify(payload, headers);
// } catch (error) {
// throw new BadRequestError("Payload verification failed");
// }
// parse event
let event: z.infer<typeof payloadSchema>;
try {
event = payloadSchema.parse(JSON.parse(payload));
} catch (error) {
throw new BadRequestError("Invalid webhook payload");
}
const logger = new PrefixLogger(`composio-trigger-webhook-[${event.type}]-[${event.data.trigger_nano_id}]`);
// create a job for each deployment across all pages
const msg: z.infer<typeof UserMessage> = {
role: "user",
content: `This chat is being invoked through a trigger. Here is the trigger data:\n\n${JSON.stringify(event, null, 2)}`,
};
// fetch registered trigger deployments for this event type
let cursor: string | null = null;
let jobs = 0;
do {
const triggerDeployments = await this.composioTriggerDeploymentsRepository.listByTriggerId(event.data.trigger_nano_id, cursor || undefined);
// create a job for each deployment in the current page
for (const deployment of triggerDeployments.items) {
// fetch project
const project = await this.projectsRepository.fetch(deployment.projectId);
if (!project) {
logger.log(`Project ${deployment.projectId} not found`);
continue;
}
// ensure workflow
if (!project.liveWorkflow) {
logger.log(`Project ${deployment.projectId} has no live workflow`);
continue;
}
// create job
const job = await this.jobsRepository.create({
reason: {
type: "composio_trigger",
triggerId: event.data.trigger_nano_id,
triggerDeploymentId: deployment.id,
triggerTypeSlug: deployment.triggerTypeSlug,
payload: event,
},
projectId: deployment.projectId,
input: {
workflow: project.liveWorkflow,
messages: [msg],
},
});
// notify workers
await this.pubSubService.publish('new_jobs', job.id);
jobs++;
logger.log(`Created job ${job.id} for trigger deployment ${deployment.id}`);
}
// check if there are more pages
cursor = triggerDeployments.nextCursor;
} while (cursor);
logger.log(`Created ${jobs} jobs for trigger ${event.data.trigger_nano_id}`);
}
}

View file

@ -44,7 +44,7 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
async execute(data: z.infer<typeof inputSchema>): Promise<{ key: string }> {
// fetch conversation
const conversation = await this.conversationsRepository.getConversation(data.conversationId);
const conversation = await this.conversationsRepository.fetch(data.conversationId);
if (!conversation) {
throw new NotFoundError('Conversation not found');
}

View file

@ -8,7 +8,7 @@ import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
caller: z.enum(["user", "api", "job_worker"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
@ -45,16 +45,18 @@ export class CreateConversationUseCase implements ICreateConversationUseCase {
let workflow = data.workflow;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller,
userId,
apiKey,
projectId,
});
if (caller !== "job_worker") {
await this.projectActionAuthorizationPolicy.authorize({
caller,
userId,
apiKey,
projectId,
});
}
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// if workflow is not provided, fetch workflow
if (!workflow) {
const project = await projectsCollection.findOne({
@ -71,7 +73,7 @@ export class CreateConversationUseCase implements ICreateConversationUseCase {
}
// create conversation
return await this.conversationsRepository.createConversation({
return await this.conversationsRepository.create({
projectId,
workflow,
isLiveWorkflow,

View file

@ -51,7 +51,7 @@ export class FetchCachedTurnUseCase implements IFetchCachedTurnUseCase {
const cachedTurn = CachedTurnRequest.parse(JSON.parse(payload));
// fetch conversation
const conversation = await this.conversationsRepository.getConversation(cachedTurn.conversationId);
const conversation = await this.conversationsRepository.fetch(cachedTurn.conversationId);
if (!conversation) {
throw new NotFoundError('Conversation not found');
}

View file

@ -0,0 +1,62 @@
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 { IConversationsRepository } from '../../repositories/conversations.repository.interface';
import { Conversation } from '@/src/entities/models/conversation';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
conversationId: z.string(),
});
export interface IFetchConversationUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>>;
}
export class FetchConversationUseCase implements IFetchConversationUseCase {
private readonly conversationsRepository: IConversationsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
conversationsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
conversationsRepository: IConversationsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.conversationsRepository = conversationsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>> {
// fetch conversation first to get projectId
const conversation = await this.conversationsRepository.fetch(request.conversationId);
if (!conversation) {
throw new NotFoundError(`Conversation ${request.conversationId} not found`);
}
// extract projectid from conversation
const { projectId } = conversation;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// return the conversation
return conversation;
}
}

View file

@ -0,0 +1,59 @@
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 { IConversationsRepository, ListedConversationItem } from '../../repositories/conversations.repository.interface';
import { Conversation } from '@/src/entities/models/conversation';
import { PaginatedList } from '@/src/entities/common/paginated-list';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
export interface IListConversationsUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>>;
}
export class ListConversationsUseCase implements IListConversationsUseCase {
private readonly conversationsRepository: IConversationsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
conversationsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
conversationsRepository: IConversationsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.conversationsRepository = conversationsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>> {
// extract projectid from request
const { projectId, limit } = request;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// fetch conversations for project
return await this.conversationsRepository.list(projectId, request.cursor, limit);
}
}

View file

@ -10,11 +10,11 @@ import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
caller: z.enum(["user", "api", "job_worker"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
conversationId: z.string(),
trigger: Turn.shape.trigger,
reason: Turn.shape.reason,
input: Turn.shape.input,
});
@ -43,7 +43,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
async *execute(data: z.infer<typeof inputSchema>): AsyncGenerator<z.infer<typeof TurnEvent>, void, unknown> {
// fetch conversation
const conversation = await this.conversationsRepository.getConversation(data.conversationId);
const conversation = await this.conversationsRepository.fetch(data.conversationId);
if (!conversation) {
throw new NotFoundError('Conversation not found');
}
@ -52,12 +52,14 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
const { id: conversationId, projectId } = conversation;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: data.caller,
userId: data.userId,
apiKey: data.apiKey,
projectId,
});
if (data.caller !== "job_worker") {
await this.projectActionAuthorizationPolicy.authorize({
caller: data.caller,
userId: data.userId,
apiKey: data.apiKey,
projectId,
});
}
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
@ -129,7 +131,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
} else {
// save turn data
const turn = await this.conversationsRepository.addTurn(data.conversationId, {
trigger: data.trigger,
reason: data.reason,
input: data.input,
output: outputMessages,
});

View file

@ -0,0 +1,62 @@
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 { IJobsRepository } from '../../repositories/jobs.repository.interface';
import { Job } from '@/src/entities/models/job';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
jobId: z.string(),
});
export interface IFetchJobUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>>;
}
export class FetchJobUseCase implements IFetchJobUseCase {
private readonly jobsRepository: IJobsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
jobsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
jobsRepository: IJobsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.jobsRepository = jobsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>> {
// fetch job first to get projectId
const job = await this.jobsRepository.fetch(request.jobId);
if (!job) {
throw new NotFoundError(`Job ${request.jobId} not found`);
}
// extract projectid from job
const { projectId } = job;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// return the job
return job;
}
}

View file

@ -0,0 +1,59 @@
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 { IJobsRepository, ListedJobItem } from '../../repositories/jobs.repository.interface';
import { Job } from '@/src/entities/models/job';
import { PaginatedList } from '@/src/entities/common/paginated-list';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
export interface IListJobsUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
}
export class ListJobsUseCase implements IListJobsUseCase {
private readonly jobsRepository: IJobsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
jobsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
jobsRepository: IJobsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.jobsRepository = jobsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
// extract projectid from request
const { projectId, limit } = request;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// fetch jobs for project
return await this.jobsRepository.list(projectId, request.cursor, limit);
}
}

View file

@ -0,0 +1,250 @@
import { IJobsRepository } from "@/src/application/repositories/jobs.repository.interface";
import { ICreateConversationUseCase } from "../use-cases/conversations/create-conversation.use-case";
import { IRunConversationTurnUseCase } from "../use-cases/conversations/run-conversation-turn.use-case";
import { Job } from "@/src/entities/models/job";
import { Turn } from "@/src/entities/models/turn";
import { IPubSubService, Subscription } from "../services/pub-sub.service.interface";
import { nanoid } from "nanoid";
import { z } from "zod";
import { PrefixLogger } from "@/app/lib/utils";
export interface IJobsWorker {
run(): Promise<void>;
stop(): Promise<void>;
}
export class JobsWorker implements IJobsWorker {
private readonly jobsRepository: IJobsRepository;
private readonly createConversationUseCase: ICreateConversationUseCase;
private readonly runConversationTurnUseCase: IRunConversationTurnUseCase;
private readonly pubSubService: IPubSubService;
private workerId: string;
private subscription: Subscription | null = null;
private isRunning: boolean = false;
private pollInterval: number = 5000; // 5 seconds
private logger: PrefixLogger;
private pollTimeoutId: NodeJS.Timeout | null = null;
constructor({
jobsRepository,
createConversationUseCase,
runConversationTurnUseCase,
pubSubService,
}: {
jobsRepository: IJobsRepository;
createConversationUseCase: ICreateConversationUseCase;
runConversationTurnUseCase: IRunConversationTurnUseCase;
pubSubService: IPubSubService;
}) {
this.jobsRepository = jobsRepository;
this.createConversationUseCase = createConversationUseCase;
this.runConversationTurnUseCase = runConversationTurnUseCase;
this.pubSubService = pubSubService;
this.workerId = nanoid();
this.logger = new PrefixLogger(`jobs-worker-[${this.workerId}]`);
}
async processJob(job: z.infer<typeof Job>): Promise<void> {
const logger = this.logger.child(`job-${job.id}`);
logger.log('Processing job');
try {
// extract project id from job
const { projectId } = job;
// create conversation
logger.log('Creating conversation');
const conversation = await this.createConversationUseCase.execute({
caller: "job_worker",
projectId,
workflow: job.input.workflow,
isLiveWorkflow: true,
});
logger.log(`Created conversation ${conversation.id}`);
// run turn
logger.log('Running turn');
const stream = this.runConversationTurnUseCase.execute({
caller: "job_worker",
conversationId: conversation.id,
reason: {
type: "job",
jobId: job.id,
},
input: {
messages: job.input.messages,
},
});
let turn: z.infer<typeof Turn> | null = null;
for await (const event of stream) {
logger.log(`Received event: ${event.type}`);
if (event.type === "done") {
turn = event.turn;
}
}
if (!turn) {
throw new Error("Turn not created");
}
logger.log(`Completed turn ${turn.id}`);
// update job
await this.jobsRepository.update(job.id, {
status: "completed",
output: {
conversationId: conversation.id,
turnId: turn.id,
},
});
logger.log(`Completed successfully`);
} catch (error) {
logger.log(`Failed: ${error instanceof Error ? error.message : "Unknown error"}`);
// update job
await this.jobsRepository.update(job.id, {
status: "failed",
output: {
error: error instanceof Error ? error.message : "Unknown error",
},
});
} finally {
// release job
await this.jobsRepository.release(job.id);
logger.log(`Released`);
}
}
private async handleNewJobMessage(message: string): Promise<void> {
const logger = this.logger.child(`handle-new-job-message-${message}`);
try {
const jobId = message.trim();
if (!jobId) {
logger.log("Received empty job ID");
return;
}
logger.log(`Received job ${jobId} via subscription`);
// Try to lock the specific job
let job: z.infer<typeof Job> | null = null;
try {
job = await this.jobsRepository.lock(jobId, this.workerId);
logger.log(`Successfully locked job`);
} catch (error) {
// Job might already be locked by another worker or doesn't exist
logger.log(`Failed to lock job: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
if (!job) {
logger.log("Job not found");
return;
}
logger.log(`Processing job ${job.id}`);
await this.processJob(job);
logger.log(`Processed job ${job.id}`);
} catch (error) {
logger.log(`Error handling new job message: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async pollForJobs(): Promise<void> {
const logger = this.logger.child(`poll-for-jobs`);
try {
// fetch next job
const job = await this.jobsRepository.poll(this.workerId);
// if no job found, return early
if (!job) {
return;
}
logger.log(`Found job ${job.id} via polling`);
// process job
await this.processJob(job);
} catch (error) {
logger.log(`Error polling for jobs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async startPolling(): Promise<void> {
const logger = this.logger.child(`start-polling`);
logger.log("Starting polling mechanism");
const scheduleNextPoll = () => {
this.pollTimeoutId = setTimeout(async () => {
await this.pollForJobs();
// Schedule the next poll after this one completes
scheduleNextPoll();
}, this.pollInterval);
};
// Start the first poll
scheduleNextPoll();
}
private async startSubscription(): Promise<void> {
const logger = this.logger.child(`start-subscription`);
try {
logger.log("Subscribing to new_jobs topic");
this.subscription = await this.pubSubService.subscribe(
'new_jobs',
(message: string) => {
// Handle the message asynchronously to avoid blocking the subscription
this.handleNewJobMessage(message).catch(error => {
logger.log(`Error handling subscription message: ${error instanceof Error ? error.message : 'Unknown error'}`);
});
}
);
logger.log("Successfully subscribed to new_jobs topic");
} catch (error) {
logger.log(`Failed to subscribe to new_jobs topic: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async run(): Promise<void> {
if (this.isRunning) {
this.logger.log("Worker is already running");
return;
}
this.isRunning = true;
this.logger.log(`Starting worker ${this.workerId}`);
try {
// Start subscription to new_jobs topic
await this.startSubscription();
// Start polling as a fallback mechanism (run concurrently)
// We run both operations concurrently - the subscription will handle immediate jobs
// while polling will catch any jobs that slipped through
await this.startPolling();
} catch (error) {
this.logger.log(`Error in worker run loop: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
this.isRunning = false;
this.logger.log("Worker run loop ended");
}
}
async stop(): Promise<void> {
this.logger.log(`Stopping worker ${this.workerId}`);
this.isRunning = false;
// Clear any pending polls
if (this.pollTimeoutId) {
clearTimeout(this.pollTimeoutId);
this.pollTimeoutId = null;
this.logger.log("Cleared pending poll timeout");
}
// Unsubscribe from the topic
if (this.subscription) {
try {
await this.subscription.unsubscribe();
this.logger.log("Successfully unsubscribed from new_jobs topic");
} catch (error) {
this.logger.log(`Error unsubscribing from new_jobs topic: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
this.subscription = null;
}
}
}

View file

@ -0,0 +1,6 @@
import { z } from "zod";
export const PaginatedList = <T extends z.ZodTypeAny>(schema: T) => z.object({
items: z.array(schema),
nextCursor: z.string().nullable(),
});

View file

@ -0,0 +1,5 @@
export class JobAcquisitionError extends Error {
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
}
}

View file

@ -0,0 +1,14 @@
import { z } from "zod";
export const ComposioTriggerDeployment = z.object({
id: z.string(),
projectId: z.string(),
triggerId: z.string(),
toolkitSlug: z.string(),
triggerTypeSlug: z.string(),
connectedAccountId: z.string(),
triggerConfig: z.record(z.string(), z.unknown()),
logo: z.string(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});

View file

@ -0,0 +1,13 @@
import { z } from "zod";
export const ComposioTriggerType = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
config: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
title: z.string().optional(),
}),
});

View file

@ -0,0 +1,38 @@
import { Message } from "@/app/lib/types/types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { z } from "zod";
const composioTriggerReason = z.object({
type: z.literal("composio_trigger"),
triggerId: z.string(),
triggerDeploymentId: z.string(),
triggerTypeSlug: z.string(),
payload: z.object({}).passthrough(),
});
const reason = composioTriggerReason;
export const Job = z.object({
id: z.string(),
reason,
projectId: z.string(),
input: z.object({
workflow: Workflow,
messages: z.array(Message),
}),
output: z.object({
conversationId: z.string().optional(),
turnId: z.string().optional(),
error: z.string().optional(),
}).optional(),
workerId: z.string().nullable(),
lastWorkerId: z.string().nullable(),
status: z.enum([
"pending",
"running",
"completed",
"failed",
]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime().optional(),
});

View file

@ -0,0 +1,10 @@
import { z } from "zod";
import { Project as ExistingProjectSchema } from "@/app/lib/types/project_types";
export const Project = ExistingProjectSchema
.omit({
_id: true,
})
.extend({
id: z.string().uuid(),
});

View file

@ -1,12 +1,28 @@
import { Message } from "@/app/lib/types/types";
import { z } from "zod";
const chatReason = z.object({
type: z.literal("chat"),
});
const apiReason = z.object({
type: z.literal("api"),
});
const jobReason = z.object({
type: z.literal("job"),
jobId: z.string(),
});
const reason = z.discriminatedUnion("type", [
chatReason,
apiReason,
jobReason,
]);
export const Turn = z.object({
id: z.string(),
trigger: z.enum([
"chat",
"api",
]),
reason,
input: z.object({
messages: z.array(Message),
mockTools: z.record(z.string(), z.string()).nullable().optional(),

View file

@ -0,0 +1,188 @@
import { z } from "zod";
import { ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb";
import { CreateDeploymentSchema, IComposioTriggerDeploymentsRepository } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
import { PaginatedList } from "@/src/entities/common/paginated-list";
/**
* MongoDB document schema for ComposioTriggerDeployment.
* Excludes the 'id' field as it's represented by MongoDB's '_id'.
*/
const DocSchema = ComposioTriggerDeployment.omit({
id: true,
});
/**
* MongoDB implementation of the ComposioTriggerDeploymentsRepository.
*
* This repository manages Composio trigger deployments in MongoDB,
* providing CRUD operations and paginated queries for deployments.
*/
export class MongodbComposioTriggerDeploymentsRepository implements IComposioTriggerDeploymentsRepository {
private readonly collection = db.collection<z.infer<typeof DocSchema>>("composio_trigger_deployments");
constructor() {
// Create indexes for efficient querying
this.createIndexes();
}
/**
* Creates the necessary indexes for efficient querying.
*/
private async createIndexes(): Promise<void> {
await this.collection.createIndexes([
{ key: { projectId: 1 }, name: "projectId_idx" },
{ key: { triggerTypeSlug: 1 }, name: "triggerTypeSlug_idx" },
{ key: { connectedAccountId: 1 }, name: "connectedAccountId_idx" },
{ key: { triggerId: 1 }, name: "triggerId_idx" },
]);
}
/**
* Creates a new Composio trigger deployment.
*/
async create(data: z.infer<typeof CreateDeploymentSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
const now = new Date().toISOString();
const _id = new ObjectId();
const doc = {
...data,
createdAt: now,
updatedAt: now,
};
await this.collection.insertOne({
...doc,
_id,
});
return {
...doc,
id: _id.toString(),
};
}
/**
* Fetches a trigger deployment by its ID.
*/
async fetch(id: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null> {
const result = await this.collection.findOne({ _id: new ObjectId(id) });
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
id: _id.toString(),
};
}
/**
* Deletes a Composio trigger deployment by its ID.
*/
async delete(id: string): Promise<boolean> {
const result = await this.collection.deleteOne({
_id: new ObjectId(id),
});
return result.deletedCount > 0;
}
/**
* Fetches a trigger deployment by its trigger type slug and connected account ID.
*/
async fetchBySlugAndConnectedAccountId(triggerTypeSlug: string, connectedAccountId: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null> {
const result = await this.collection.findOne({
triggerTypeSlug,
connectedAccountId,
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
id: _id.toString(),
};
}
/**
* Retrieves all trigger deployments for a specific project with pagination.
*/
async listByProjectId(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
const query: any = { projectId };
if (cursor) {
query._id = { $gt: new ObjectId(cursor) };
}
const results = await this.collection
.find(query)
.sort({ _id: 1 })
.limit(limit + 1) // Fetch one extra to determine if there's a next page
.toArray();
const hasNextPage = results.length > limit;
const items = results.slice(0, limit).map(doc => {
const { _id, ...rest } = doc;
return {
...rest,
id: _id.toString(),
};
});
return {
items,
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
};
}
/**
* Retrieves all trigger deployments for a specific trigger with pagination.
*/
async listByTriggerId(triggerId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
const query: any = { triggerId };
if (cursor) {
query._id = { $gt: new ObjectId(cursor) };
}
const results = await this.collection
.find(query)
.sort({ _id: 1 })
.limit(limit + 1) // Fetch one extra to determine if there's a next page
.toArray();
const hasNextPage = results.length > limit;
const items = results.slice(0, limit).map(doc => {
const { _id, ...rest } = doc;
return {
...rest,
id: _id.toString(),
};
});
return {
items,
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
};
}
/**
* Deletes all trigger deployments associated with a specific connected account.
*/
async deleteByConnectedAccountId(connectedAccountId: string): Promise<number> {
const result = await this.collection.deleteMany({
connectedAccountId,
});
return result.deletedCount;
}
}

View file

@ -1,10 +1,11 @@
import { z } from "zod";
import { db } from "@/app/lib/mongodb";
import { ObjectId } from "mongodb";
import { AddTurnData, CreateConversationData, IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface";
import { AddTurnData, CreateConversationData, IConversationsRepository, ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
import { Conversation } from "@/src/entities/models/conversation";
import { nanoid } from "nanoid";
import { Turn } from "@/src/entities/models/turn";
import { PaginatedList } from "@/src/entities/common/paginated-list";
const DocSchema = Conversation
.omit({
@ -14,7 +15,7 @@ const DocSchema = Conversation
export class MongoDBConversationsRepository implements IConversationsRepository {
private readonly collection = db.collection<z.infer<typeof DocSchema>>("conversations");
async createConversation(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>> {
async create(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>> {
const now = new Date();
const _id = new ObjectId();
@ -35,7 +36,7 @@ export class MongoDBConversationsRepository implements IConversationsRepository
};
}
async getConversation(id: string): Promise<z.infer<typeof Conversation> | null> {
async fetch(id: string): Promise<z.infer<typeof Conversation> | null> {
const result = await this.collection.findOne({
_id: new ObjectId(id),
});
@ -73,4 +74,38 @@ export class MongoDBConversationsRepository implements IConversationsRepository
return turn;
}
async list(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>> {
const query: any = { projectId };
if (cursor) {
query._id = { $lt: new ObjectId(cursor) };
}
const results = await this.collection
.find(query)
.sort({ _id: -1 })
.limit(limit + 1) // Fetch one extra to determine if there's a next page
.project<z.infer<typeof ListedConversationItem> & { _id: ObjectId }>({
_id: 1,
projectId: 1,
createdAt: 1,
updatedAt: 1,
})
.toArray();
const hasNextPage = results.length > limit;
const items = results.slice(0, limit).map(doc => {
const { _id, ...rest } = doc;
return {
...rest,
id: _id.toString(),
};
});
return {
items,
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
};
}
}

View file

@ -0,0 +1,255 @@
import { z } from "zod";
import { ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb";
import { IJobsRepository, ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
import { Job } from "@/src/entities/models/job";
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list";
/**
* MongoDB document schema for Job.
* Excludes the 'id' field as it's represented by MongoDB's '_id'.
*/
const DocSchema = Job.omit({
id: true,
});
/**
* Schema for creating a new job.
*/
const createJobSchema = Job.pick({
reason: true,
projectId: true,
input: true,
});
/**
* Schema for updating an existing job.
*/
const updateJobSchema = Job.pick({
status: true,
output: true,
});
/**
* MongoDB implementation of the JobsRepository.
*
* This repository manages jobs in MongoDB, providing operations for
* creating, polling, locking, updating, and releasing jobs for worker processing.
*/
export class MongoDBJobsRepository implements IJobsRepository {
private readonly collection = db.collection<z.infer<typeof DocSchema>>("jobs");
/**
* Creates a new job in the system.
*/
async create(data: z.infer<typeof createJobSchema>): Promise<z.infer<typeof Job>> {
const now = new Date().toISOString();
const _id = new ObjectId();
const doc: z.infer<typeof DocSchema> = {
...data,
status: "pending" as const,
workerId: null,
lastWorkerId: null,
createdAt: now,
};
await this.collection.insertOne({
...doc,
_id,
});
return {
...doc,
id: _id.toString(),
};
}
/**
* Fetches a job by its unique identifier.
*/
async fetch(id: string): Promise<z.infer<typeof Job> | null> {
const result = await this.collection.findOne({ _id: new ObjectId(id) });
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
id: _id.toString(),
};
}
/**
* Polls for the next available job that can be processed by a worker.
*/
async poll(workerId: string): Promise<z.infer<typeof Job> | null> {
const now = new Date().toISOString();
// Find and update the next available job atomically
const result = await this.collection.findOneAndUpdate(
{
status: "pending",
workerId: null,
},
{
$set: {
status: "running",
workerId,
lastWorkerId: workerId,
updatedAt: now,
},
},
{
sort: { createdAt: 1 }, // Process oldest jobs first
returnDocument: "after",
}
);
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
id: _id.toString(),
};
}
/**
* Locks a specific job for processing by a worker.
*/
async lock(id: string, workerId: string): Promise<z.infer<typeof Job>> {
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{
_id: new ObjectId(id),
status: "pending",
workerId: null,
},
{
$set: {
status: "running",
workerId,
lastWorkerId: workerId,
updatedAt: now,
},
},
{
returnDocument: "after",
}
);
if (!result) {
throw new JobAcquisitionError(`Job ${id} is already locked or doesn't exist`);
}
const { _id, ...rest } = result;
return {
...rest,
id: _id.toString(),
};
}
/**
* Updates an existing job with new status and/or output data.
*/
async update(id: string, data: z.infer<typeof updateJobSchema>): Promise<z.infer<typeof Job>> {
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{
_id: new ObjectId(id),
},
{
$set: {
...data,
updatedAt: now,
},
},
{
returnDocument: "after",
}
);
if (!result) {
throw new NotFoundError(`Job ${id} not found`);
}
const { _id, ...rest } = result;
return {
...rest,
id: _id.toString(),
};
}
/**
* Releases a job lock, making it available for other workers.
*/
async release(id: string): Promise<void> {
const result = await this.collection.updateOne(
{
_id: new ObjectId(id),
},
{
$set: {
workerId: null,
updatedAt: new Date().toISOString(),
},
}
);
if (result.matchedCount === 0) {
throw new NotFoundError(`Job ${id} not found`);
}
}
/**
* Lists jobs for a specific project with pagination.
*/
async list(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
const query: any = { projectId };
if (cursor) {
query._id = { $lt: new ObjectId(cursor) };
}
const results = await this.collection
.find(query)
.sort({ _id: -1 })
.limit(limit + 1) // Fetch one extra to determine if there's a next page
.project<z.infer<typeof ListedJobItem> & { _id: ObjectId }>({
_id: 1,
projectId: 1,
status: 1,
reason: 1,
createdAt: 1,
updatedAt: 1,
})
.toArray();
const hasNextPage = results.length > limit;
const items = results.slice(0, limit).map(doc => {
const { _id, ...rest } = doc;
return {
...rest,
id: _id.toString(),
};
});
return {
items,
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
};
}
}

View file

@ -0,0 +1,31 @@
import { IProjectsRepository } from "@/src/application/repositories/projects.repository.interface";
import { Project } from "@/src/entities/models/project";
import { projectsCollection } from "@/app/lib/mongodb";
import { z } from "zod";
const docSchema = Project
.omit({
id: true,
})
.extend({
id: z.string().uuid(),
});
export class MongodbProjectsRepository implements IProjectsRepository {
async fetch(id: string): Promise<z.infer<typeof docSchema> | null> {
const doc = await projectsCollection.findOne({ _id: id });
if (!doc) {
return null;
}
const { _id, ...rest } = doc;
return {
...rest,
id: _id.toString(),
}
}
async deleteComposioConnectedAccount(projectId: string, toolkitSlug: string): Promise<boolean> {
const result = await projectsCollection.updateOne({ _id: projectId }, { $unset: { [`composioConnectedAccounts.${toolkitSlug}`]: "" } });
return result.modifiedCount > 0;
}
}

View file

@ -0,0 +1,127 @@
import { IPubSubService, Subscription } from "@/src/application/services/pub-sub.service.interface";
import { redisClient } from "@/app/lib/redis";
import Redis from 'ioredis';
/**
* Redis implementation of the pub-sub service interface.
*
* This service uses Redis pub-sub functionality to provide a distributed
* messaging system where publishers can send messages to channels and
* subscribers can receive messages from those channels.
*
* Features:
* - Distributed messaging across multiple application instances
* - Automatic message delivery to all subscribers
* - Support for multiple channels
* - Asynchronous message handling
*/
export class RedisPubSubService implements IPubSubService {
private subscriptions = new Map<string, Set<(message: string) => void>>();
private redisSubscriber: Redis | null = null;
constructor() {
this.setupRedisSubscriber();
}
/**
* Sets up the Redis subscriber connection for receiving messages.
* This creates a separate Redis connection specifically for subscriptions
* to avoid blocking the main Redis client.
*/
private setupRedisSubscriber(): void {
this.redisSubscriber = new Redis(process.env.REDIS_URL || '');
this.redisSubscriber.on('message', (channel: string, message: string) => {
const handlers = this.subscriptions.get(channel);
if (handlers) {
handlers.forEach(handler => {
try {
handler(message);
} catch (error) {
console.error(`Error in pub-sub handler for channel ${channel}:`, error);
}
});
}
});
this.redisSubscriber.on('error', (error: Error) => {
console.error('Redis pub-sub subscriber error:', error);
});
}
/**
* Publishes a message to a specific channel.
*
* @param channel - The channel name to publish the message to
* @param message - The message content to publish
* @returns A promise that resolves when the message has been published
* @throws {Error} If the publish operation fails
*/
async publish(channel: string, message: string): Promise<void> {
try {
await redisClient.publish(channel, message);
} catch (error) {
console.error(`Failed to publish message to channel ${channel}:`, error);
throw new Error(`Failed to publish message to channel ${channel}: ${error}`);
}
}
/**
* Subscribes to a channel to receive messages.
*
* @param channel - The channel name to subscribe to
* @param handler - A function that will be called when messages are received
* @returns A promise that resolves to a Subscription object
* @throws {Error} If the subscribe operation fails
*/
async subscribe(channel: string, handler: (message: string) => void): Promise<Subscription> {
try {
// Add handler to local subscriptions map
if (!this.subscriptions.has(channel)) {
this.subscriptions.set(channel, new Set());
}
this.subscriptions.get(channel)!.add(handler);
// Subscribe to the channel in Redis if this is the first handler
if (this.subscriptions.get(channel)!.size === 1 && this.redisSubscriber) {
await this.redisSubscriber.subscribe(channel);
}
// Return subscription object for cleanup
return {
unsubscribe: async (): Promise<void> => {
await this.unsubscribe(channel, handler);
}
};
} catch (error) {
console.error(`Failed to subscribe to channel ${channel}:`, error);
throw new Error(`Failed to subscribe to channel ${channel}: ${error}`);
}
}
/**
* Unsubscribes a specific handler from a channel.
*
* @param channel - The channel name to unsubscribe from
* @param handler - The handler function to remove
*/
private async unsubscribe(channel: string, handler: (message: string) => void): Promise<void> {
try {
const handlers = this.subscriptions.get(channel);
if (handlers) {
handlers.delete(handler);
// If no more handlers for this channel, unsubscribe from Redis
if (handlers.size === 0) {
this.subscriptions.delete(channel);
if (this.redisSubscriber) {
await this.redisSubscriber.unsubscribe(channel);
}
}
}
} catch (error) {
console.error(`Failed to unsubscribe from channel ${channel}:`, error);
throw new Error(`Failed to unsubscribe from channel ${channel}: ${error}`);
}
}
}

View file

@ -0,0 +1,48 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { ICreateComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
import { CreateDeploymentSchema } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
data: CreateDeploymentSchema.omit({
triggerId: true,
logo: true,
}),
});
export interface ICreateComposioTriggerDeploymentController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
}
export class CreateComposioTriggerDeploymentController implements ICreateComposioTriggerDeploymentController {
private readonly createComposioTriggerDeploymentUseCase: ICreateComposioTriggerDeploymentUseCase;
constructor({
createComposioTriggerDeploymentUseCase,
}: {
createComposioTriggerDeploymentUseCase: ICreateComposioTriggerDeploymentUseCase,
}) {
this.createComposioTriggerDeploymentUseCase = createComposioTriggerDeploymentUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, data } = result.data;
// execute use case
return await this.createComposioTriggerDeploymentUseCase.execute({
caller,
userId,
apiKey,
data,
});
}
}

View file

@ -0,0 +1,45 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IDeleteComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
deploymentId: z.string(),
});
export interface IDeleteComposioTriggerDeploymentController {
execute(request: z.infer<typeof inputSchema>): Promise<boolean>;
}
export class DeleteComposioTriggerDeploymentController implements IDeleteComposioTriggerDeploymentController {
private readonly deleteComposioTriggerDeploymentUseCase: IDeleteComposioTriggerDeploymentUseCase;
constructor({
deleteComposioTriggerDeploymentUseCase,
}: {
deleteComposioTriggerDeploymentUseCase: IDeleteComposioTriggerDeploymentUseCase,
}) {
this.deleteComposioTriggerDeploymentUseCase = deleteComposioTriggerDeploymentUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, deploymentId } = result.data;
// execute use case
return await this.deleteComposioTriggerDeploymentUseCase.execute({
caller,
userId,
apiKey,
projectId,
deploymentId,
});
}
}

View file

@ -0,0 +1,49 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IListComposioTriggerDeploymentsUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
import { PaginatedList } from "@/src/entities/common/paginated-list";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
export interface IListComposioTriggerDeploymentsController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
}
export class ListComposioTriggerDeploymentsController implements IListComposioTriggerDeploymentsController {
private readonly listComposioTriggerDeploymentsUseCase: IListComposioTriggerDeploymentsUseCase;
constructor({
listComposioTriggerDeploymentsUseCase,
}: {
listComposioTriggerDeploymentsUseCase: IListComposioTriggerDeploymentsUseCase,
}) {
this.listComposioTriggerDeploymentsUseCase = listComposioTriggerDeploymentsUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
// execute use case
return await this.listComposioTriggerDeploymentsUseCase.execute({
caller,
userId,
apiKey,
projectId,
cursor,
limit,
});
}
}

View file

@ -0,0 +1,41 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IListComposioTriggerTypesUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case";
import { ComposioTriggerType } from "@/src/entities/models/composio-trigger-type";
import { PaginatedList } from "@/src/entities/common/paginated-list";
const inputSchema = z.object({
toolkitSlug: z.string(),
cursor: z.string().optional(),
});
export interface IListComposioTriggerTypesController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>>;
}
export class ListComposioTriggerTypesController implements IListComposioTriggerTypesController {
private readonly listComposioTriggerTypesUseCase: IListComposioTriggerTypesUseCase;
constructor({
listComposioTriggerTypesUseCase,
}: {
listComposioTriggerTypesUseCase: IListComposioTriggerTypesUseCase,
}) {
this.listComposioTriggerTypesUseCase = listComposioTriggerTypesUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { toolkitSlug, cursor } = result.data;
// execute use case
return await this.listComposioTriggerTypesUseCase.execute({
toolkitSlug,
cursor,
});
}
}

View file

@ -0,0 +1,47 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IDeleteComposioConnectedAccountUseCase } from "@/src/application/use-cases/composio/delete-composio-connected-account.use-case";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
toolkitSlug: z.string(),
connectedAccountId: z.string(),
});
export interface IDeleteComposioConnectedAccountController {
execute(request: z.infer<typeof inputSchema>): Promise<void>;
}
export class DeleteComposioConnectedAccountController implements IDeleteComposioConnectedAccountController {
private readonly deleteComposioConnectedAccountUseCase: IDeleteComposioConnectedAccountUseCase;
constructor({
deleteComposioConnectedAccountUseCase,
}: {
deleteComposioConnectedAccountUseCase: IDeleteComposioConnectedAccountUseCase,
}) {
this.deleteComposioConnectedAccountUseCase = deleteComposioConnectedAccountUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<void> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, toolkitSlug, connectedAccountId } = result.data;
// execute use case
return await this.deleteComposioConnectedAccountUseCase.execute({
caller,
userId,
apiKey,
projectId,
toolkitSlug,
connectedAccountId,
});
}
}

View file

@ -0,0 +1,39 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IHandleCompsioWebhookRequestUseCase } from "@/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case";
const inputSchema = z.object({
headers: z.record(z.string(), z.string()),
payload: z.string(),
});
export interface IHandleComposioWebhookRequestController {
execute(request: z.infer<typeof inputSchema>): Promise<void>;
}
export class HandleComposioWebhookRequestController implements IHandleComposioWebhookRequestController {
private readonly handleCompsioWebhookRequestUseCase: IHandleCompsioWebhookRequestUseCase;
constructor({
handleCompsioWebhookRequestUseCase,
}: {
handleCompsioWebhookRequestUseCase: IHandleCompsioWebhookRequestUseCase,
}) {
this.handleCompsioWebhookRequestUseCase = handleCompsioWebhookRequestUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<void> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { headers, payload } = result.data;
// execute use case
return await this.handleCompsioWebhookRequestUseCase.execute({
headers,
payload,
});
}
}

View file

@ -0,0 +1,44 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IFetchConversationUseCase } from "@/src/application/use-cases/conversations/fetch-conversation.use-case";
import { Conversation } from "@/src/entities/models/conversation";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
conversationId: z.string(),
});
export interface IFetchConversationController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>>;
}
export class FetchConversationController implements IFetchConversationController {
private readonly fetchConversationUseCase: IFetchConversationUseCase;
constructor({
fetchConversationUseCase,
}: {
fetchConversationUseCase: IFetchConversationUseCase,
}) {
this.fetchConversationUseCase = fetchConversationUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, conversationId } = result.data;
// execute use case
return await this.fetchConversationUseCase.execute({
caller,
userId,
apiKey,
conversationId,
});
}
}

View file

@ -0,0 +1,50 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IListConversationsUseCase } from "@/src/application/use-cases/conversations/list-conversations.use-case";
import { Conversation } from "@/src/entities/models/conversation";
import { PaginatedList } from "@/src/entities/common/paginated-list";
import { ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
export interface IListConversationsController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>>;
}
export class ListConversationsController implements IListConversationsController {
private readonly listConversationsUseCase: IListConversationsUseCase;
constructor({
listConversationsUseCase,
}: {
listConversationsUseCase: IListConversationsUseCase,
}) {
this.listConversationsUseCase = listConversationsUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
// execute use case
return await this.listConversationsUseCase.execute({
caller,
userId,
apiKey,
projectId,
cursor,
limit,
});
}
}

View file

@ -48,7 +48,7 @@ export class RunCachedTurnController implements IRunCachedTurnController {
caller: result.data.caller,
userId: result.data.userId,
conversationId: cachedTurn.conversationId,
trigger: result.data.caller === "user" ? "chat" : "api",
reason: result.data.caller === "user" ? { type: "chat" } : { type: "api" },
input: cachedTurn.input,
});
}

View file

@ -67,7 +67,7 @@ export class RunTurnController implements IRunTurnController {
userId,
apiKey,
conversationId,
trigger: caller === "user" ? "chat" : "api",
reason: caller === "user" ? { type: "chat" } : { type: "api" },
input,
});

View file

@ -0,0 +1,44 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IFetchJobUseCase } from "@/src/application/use-cases/jobs/fetch-job.use-case";
import { Job } from "@/src/entities/models/job";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
jobId: z.string(),
});
export interface IFetchJobController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>>;
}
export class FetchJobController implements IFetchJobController {
private readonly fetchJobUseCase: IFetchJobUseCase;
constructor({
fetchJobUseCase,
}: {
fetchJobUseCase: IFetchJobUseCase,
}) {
this.fetchJobUseCase = fetchJobUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, jobId } = result.data;
// execute use case
return await this.fetchJobUseCase.execute({
caller,
userId,
apiKey,
jobId,
});
}
}

View file

@ -0,0 +1,50 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
import { Job } from "@/src/entities/models/job";
import { PaginatedList } from "@/src/entities/common/paginated-list";
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
cursor: z.string().optional(),
limit: z.number().optional(),
});
export interface IListJobsController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
}
export class ListJobsController implements IListJobsController {
private readonly listJobsUseCase: IListJobsUseCase;
constructor({
listJobsUseCase,
}: {
listJobsUseCase: IListJobsUseCase,
}) {
this.listJobsUseCase = listJobsUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
// parse input
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
// execute use case
return await this.listJobsUseCase.execute({
caller,
userId,
apiKey,
projectId,
cursor,
limit,
});
}
}