diff --git a/apps/rowboat/app/actions/composio_actions.ts b/apps/rowboat/app/actions/composio_actions.ts
index cc2e9376..21067a98 100644
--- a/apps/rowboat/app/actions/composio_actions.ts
+++ b/apps/rowboat/app/actions/composio_actions.ts
@@ -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("createComposioTriggerDeploymentController");
+const listComposioTriggerDeploymentsController = container.resolve("listComposioTriggerDeploymentsController");
+const deleteComposioTriggerDeploymentController = container.resolve("deleteComposioTriggerDeploymentController");
+const listComposioTriggerTypesController = container.resolve("listComposioTriggerTypesController");
+const deleteComposioConnectedAccountController = container.resolve("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 {
- 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,
+}) {
+ 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,
+ });
}
\ No newline at end of file
diff --git a/apps/rowboat/app/actions/conversation_actions.ts b/apps/rowboat/app/actions/conversation_actions.ts
new file mode 100644
index 00000000..14e4e1e9
--- /dev/null
+++ b/apps/rowboat/app/actions/conversation_actions.ts
@@ -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('listConversationsController');
+const fetchConversationController = container.resolve('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,
+ });
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/actions/job_actions.ts b/apps/rowboat/app/actions/job_actions.ts
new file mode 100644
index 00000000..69d4ca16
--- /dev/null
+++ b/apps/rowboat/app/actions/job_actions.ts
@@ -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('listJobsController');
+const fetchJobController = container.resolve('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,
+ });
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/api/composio/webhook/route.ts b/apps/rowboat/app/api/composio/webhook/route.ts
new file mode 100644
index 00000000..1adf8c55
--- /dev/null
+++ b/apps/rowboat/app/api/composio/webhook/route.ts
@@ -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("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"
+ }
+}
+*/
\ No newline at end of file
diff --git a/apps/rowboat/app/lib/agents.ts b/apps/rowboat/app/lib/agents.ts
index 3703a8f5..a8f617e1 100644
--- a/apps/rowboat/app/lib/agents.ts
+++ b/apps/rowboat/app/lib/agents.ts
@@ -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,
diff --git a/apps/rowboat/app/lib/components/message-display.tsx b/apps/rowboat/app/lib/components/message-display.tsx
new file mode 100644
index 00000000..8ee50272
--- /dev/null
+++ b/apps/rowboat/app/lib/components/message-display.tsx
@@ -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 (
+
+
+
+ TOOL CALL: {toolCall.function.name}
+
+
+ ID: {toolCall.id}
+
+
+
+
+ Arguments:
+
+
+ {toolCall.function.arguments}
+
+
+
+ );
+}
+
+export function MessageDisplay({ message, index }: { message: z.infer; 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 (
+
+
+ {/* Message Header */}
+
+
+ {getRoleLabel()}
+
+
+ {timestamp && (
+
+ {timestamp}
+
+ )}
+
+ #{index + 1}
+
+
+
+
+ {/* Message Content */}
+
+ {isTool ? (
+
+ {getMessageContent()}
+
+ ) : (
+
+ {getMessageContent()}
+
+ )}
+
+
+ {/* Tool Calls Display */}
+ {isAssistant && 'toolCalls' in message && message.toolCalls && message.toolCalls.length > 0 && (
+
+
+ TOOL CALLS ({message.toolCalls.length})
+
+ {message.toolCalls.map((toolCall, toolIndex) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/rowboat/app/lib/composio/composio.ts b/apps/rowboat/app/lib/composio/composio.ts
index f4057047..b3accc19 100644
--- a/apps/rowboat/app/lib/composio/composio.ts
+++ b/apps/rowboat/app/lib/composio/composio.ts
@@ -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 = (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>>> {
+ 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());
}
\ No newline at end of file
diff --git a/apps/rowboat/app/lib/copilot/copilot.ts b/apps/rowboat/app/lib/copilot/copilot.ts
index 6a02ca20..4bd7b483 100644
--- a/apps/rowboat/app/lib/copilot/copilot.ts
+++ b/apps/rowboat/app/lib/copilot/copilot.ts
@@ -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 {
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', {
diff --git a/apps/rowboat/app/projects/[projectId]/conversations/[conversationId]/page.tsx b/apps/rowboat/app/projects/[projectId]/conversations/[conversationId]/page.tsx
new file mode 100644
index 00000000..cf338570
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/conversations/[conversationId]/page.tsx
@@ -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 ;
+}
+
+
diff --git a/apps/rowboat/app/projects/[projectId]/conversations/components/conversation-view.tsx b/apps/rowboat/app/projects/[projectId]/conversations/components/conversation-view.tsx
new file mode 100644
index 00000000..beb99005
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/conversations/components/conversation-view.tsx
@@ -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['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 (
+
+ {label}
+
+ );
+}
+
+function TurnReasonWithLink({ reason, projectId }: { reason: z.infer['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 (
+
+ {label}
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+}
+
+function TurnContainer({ turn, index, projectId }: { turn: z.infer; index: number; projectId: string }) {
+ return (
+
+ {/* Turn Header */}
+
+
+
+
+ TURN #{index + 1}
+
+
+
+
+ {new Date(turn.createdAt).toLocaleTimeString()}
+
+
+
+
+ {/* Turn Content */}
+
+ {/* Input Messages */}
+ {turn.input.messages && turn.input.messages.length > 0 && (
+
+
+ Input Messages ({turn.input.messages.length})
+
+
+ {turn.input.messages.map((message, msgIndex) => (
+
+ ))}
+
+
+ )}
+
+ {/* Output Messages */}
+ {turn.output && turn.output.length > 0 && (
+
+
+ Output Messages ({turn.output.length})
+
+
+ {turn.output.map((message, msgIndex) => (
+
+ ))}
+
+
+ )}
+
+ {/* Error Display */}
+ {turn.error && (
+
+
+ Error
+
+
+ {turn.error}
+
+
+ )}
+
+
+ );
+}
+
+export function ConversationView({ projectId, conversationId }: { projectId: string; conversationId: string; }) {
+ const [conversation, setConversation] = useState | null>(null);
+ const [loading, setLoading] = useState(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 (
+ {title}
}
+ rightActions={}
+ >
+
+
+ {loading && (
+
+ )}
+ {!loading && conversation && (
+
+ {/* Conversation Metadata */}
+
+
+
+ Conversation ID:
+ {conversation.id}
+
+
+ Created:
+
+ {new Date(conversation.createdAt).toLocaleString()}
+
+
+ {conversation.updatedAt && (
+
+ Updated:
+
+ {new Date(conversation.updatedAt).toLocaleString()}
+
+
+ )}
+
+ Live Workflow:
+
+ {conversation.isLiveWorkflow ? 'Yes' : 'No'}
+
+
+
+
+
+ {/* Turns */}
+ {conversation.turns && conversation.turns.length > 0 ? (
+
+
+ Turns ({conversation.turns.length})
+
+ {conversation.turns.map((turn, index) => (
+
+ ))}
+
+ ) : (
+
+
No turns in this conversation.
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+
diff --git a/apps/rowboat/app/projects/[projectId]/conversations/components/conversations-list.tsx b/apps/rowboat/app/projects/[projectId]/conversations/components/conversations-list.tsx
new file mode 100644
index 00000000..5e89dcbf
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/conversations/components/conversations-list.tsx
@@ -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;
+
+export function ConversationsList({ projectId }: { projectId: string }) {
+ const [items, setItems] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(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 = {
+ 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 (
+
+
+ CONVERSATIONS
+
+
+ }
+ rightActions={
+
+ {/* Reserved for future actions */}
+
+ }
+ >
+
+
+ {loading && (
+
+ )}
+ {!loading && items.length === 0 && (
+
No conversations yet.
+ )}
+ {!loading && items.length > 0 && (
+
+ {Object.entries(sections).map(([label, group]) => (
+ group.length > 0 ? (
+
+
{label}
+
+
+
+
+ | Conversation |
+ Created |
+
+
+
+ {group.map((c) => (
+
+ |
+
+ {c.id}
+
+ |
+
+ {new Date(c.createdAt).toLocaleString()}
+ |
+
+ ))}
+
+
+
+
+ ) : null
+ ))}
+ {hasMore && (
+
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+
diff --git a/apps/rowboat/app/projects/[projectId]/conversations/page.tsx b/apps/rowboat/app/projects/[projectId]/conversations/page.tsx
new file mode 100644
index 00000000..529468a7
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/conversations/page.tsx
@@ -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 ;
+}
+
+
diff --git a/apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx b/apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx
new file mode 100644
index 00000000..37bc1227
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx
@@ -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 ;
+}
diff --git a/apps/rowboat/app/projects/[projectId]/jobs/components/job-view.tsx b/apps/rowboat/app/projects/[projectId]/jobs/components/job-view.tsx
new file mode 100644
index 00000000..97bb77cd
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/jobs/components/job-view.tsx
@@ -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 | null>(null);
+ const [loading, setLoading] = useState(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 (
+ {title}
}
+ rightActions={}
+ >
+
+
+ {loading && (
+
+ )}
+ {!loading && job && (
+
+ {/* Job Metadata */}
+
+
+
+ Job ID:
+ {job.id}
+
+
+ Status:
+
+ {job.status}
+
+
+
+ Created:
+
+ {new Date(job.createdAt).toLocaleString()}
+
+
+ {job.updatedAt && (
+
+ Updated:
+
+ {new Date(job.updatedAt).toLocaleString()}
+
+
+ )}
+ {conversationId && (
+
+ Conversation:
+
+ {conversationId}
+
+
+ )}
+ {turnId && (
+
+ Turn:
+
+ {turnId}
+
+
+ )}
+ {job.output?.error && (
+
+ Error:
+
+ {job.output.error}
+
+
+ )}
+
+
+
+ {/* Job Reason */}
+ {reasonInfo && (
+
+
+ Job Reason
+
+
+
+
+ {reasonInfo.type}
+
+
+ {Object.entries(reasonInfo.details).map(([key, value]) => (
+
+ {key}:
+ {value}
+
+ ))}
+
+
+
+ {reasonInfo.payload && Object.keys(reasonInfo.payload).length > 0 && (
+
+
+ Trigger Payload
+
+
+ {JSON.stringify(reasonInfo.payload, null, 2)}
+
+
+ )}
+
+
+ )}
+
+ {/* Job Input */}
+
+
+ Job Input
+
+
+ {/* Messages */}
+
+
+ Messages ({job.input.messages.length})
+
+
+ {job.input.messages.map((message, msgIndex) => (
+
+ ))}
+
+
+
+ {/* Workflow */}
+
+
+ Workflow
+
+
+ {JSON.stringify(job.input.workflow, null, 2)}
+
+
+
+
+
+ {/* Job Output */}
+ {job.output && (
+
+
+ Job Output
+
+
+ {JSON.stringify(job.output, null, 2)}
+
+
+ )}
+
+ )}
+ {!loading && !job && (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/rowboat/app/projects/[projectId]/jobs/components/jobs-list.tsx b/apps/rowboat/app/projects/[projectId]/jobs/components/jobs-list.tsx
new file mode 100644
index 00000000..4310872b
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/jobs/components/jobs-list.tsx
@@ -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;
+
+export function JobsList({ projectId }: { projectId: string }) {
+ const [items, setItems] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(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 = {
+ 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 (
+
+
+ JOBS
+
+
+ }
+ rightActions={
+
+ {/* Reserved for future actions */}
+
+ }
+ >
+
+
+ {loading && (
+
+ )}
+ {!loading && items.length === 0 && (
+
No jobs yet.
+ )}
+ {!loading && items.length > 0 && (
+
+ {Object.entries(sections).map(([label, group]) => (
+ group.length > 0 ? (
+
+
{label}
+
+
+
+
+ | Job |
+ Status |
+ Reason |
+ Created |
+
+
+
+ {group.map((job) => (
+
+ |
+
+ {job.id}
+
+ |
+
+
+ {job.status}
+
+ |
+
+
+ {getReasonDisplay(job.reason)}
+
+ |
+
+ {new Date(job.createdAt).toLocaleString()}
+ |
+
+ ))}
+
+
+
+
+ ) : null
+ ))}
+ {hasMore && (
+
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/rowboat/app/projects/[projectId]/jobs/page.tsx b/apps/rowboat/app/projects/[projectId]/jobs/page.tsx
new file mode 100644
index 00000000..31c8e963
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/jobs/page.tsx
@@ -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 ;
+}
diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/SelectComposioToolkit.tsx
similarity index 88%
rename from apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx
rename to apps/rowboat/app/projects/[projectId]/tools/components/SelectComposioToolkit.tsx
index bf17dc13..2e18ab71 100644
--- a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx
+++ b/apps/rowboat/app/projects/[projectId]/tools/components/SelectComposioToolkit.tsx
@@ -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;
type ToolkitListResponse = z.infer>>;
type ProjectType = z.infer;
-interface ComposioProps {
+interface SelectComposioToolkitProps {
projectId: string;
tools: z.infer;
- onAddTool: (tool: z.infer) => 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([]);
const [projectConfig, setProjectConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
- const [selectedToolkit, setSelectedToolkit] = useState(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({
)}
-
- {/* Tools Panel */}
- {selectedToolkit && }
);
}
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx
index 0d4d90b9..ec4756b0 100644
--- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx
+++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx
@@ -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;
+
export function ToolsConfig({
projectId,
useComposioTools,
@@ -29,11 +33,28 @@ export function ToolsConfig({
defaultActiveTab = 'composio';
}
const [activeTab, setActiveTab] = useState(defaultActiveTab);
+ const [selectedToolkit, setSelectedToolkit] = useState(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) => {
+ onAddTool(tool);
+ handleCloseToolsPanel();
+ };
+
return (
-
@@ -72,6 +93,17 @@ export function ToolsConfig({
+
+ {/* Tools Panel */}
+ {selectedToolkit && (
+
+ )}
);
}
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx
new file mode 100644
index 00000000..63a67d85
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioTriggerTypesPanel.tsx
@@ -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;
+ onBack: () => void;
+ onSelectTriggerType: (triggerType: z.infer) => void;
+}
+
+type TriggerType = z.infer;
+
+export function ComposioTriggerTypesPanel({
+ toolkit,
+ onBack,
+ onSelectTriggerType,
+}: ComposioTriggerTypesPanelProps) {
+ const [triggerTypes, setTriggerTypes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [cursor, setCursor] = useState(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 (
+
+
+
+
+
+ {toolkit.name} Triggers
+
+
+ Select a trigger type to set up
+
+
+
+
+
+
+ Loading trigger types...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+ {toolkit.name} Triggers
+
+
+ Select a trigger type to set up
+
+
+
+
+
+
{error}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {toolkit.name} Triggers
+
+
+ Select a trigger type to set up ({triggerTypes.length} available)
+
+
+
+
+ {triggerTypes.length === 0 ? (
+
+
+
+ No trigger types available
+
+
+ This toolkit doesn't have any trigger types configured.
+
+
+ ) : (
+
+
+ {triggerTypes.map((triggerType) => (
+
handleTriggerTypeSelect(triggerType)}
+ >
+
+
+
+
+
+
+ {triggerType.name}
+
+
+
+
+
+ {triggerType.description}
+
+
+
+
+
+
+ ))}
+
+
+ {hasNextPage && (
+
+ : null}
+ >
+ {loadingMore ? 'Loading...' : 'Load More'}
+
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx
new file mode 100644
index 00000000..f261a9c3
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggerConfigForm.tsx
@@ -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;
+ triggerType: z.infer;
+ onBack: () => void;
+ onSubmit: (config: Record) => void;
+ isSubmitting?: boolean;
+}
+
+interface JsonSchemaProperty {
+ type: string;
+ title?: string;
+ description?: string;
+ default?: any;
+ enum?: any[];
+}
+
+interface JsonSchema {
+ type: 'object';
+ properties: Record;
+ required?: string[];
+ title?: string;
+}
+
+export function TriggerConfigForm({
+ toolkit,
+ triggerType,
+ onBack,
+ onSubmit,
+ isSubmitting = false,
+}: TriggerConfigFormProps) {
+ const [formData, setFormData] = useState>({});
+ const [errors, setErrors] = useState>({});
+
+ // Parse the JSON schema from triggerType.config
+ const schema = triggerType.config as JsonSchema;
+
+ const handleSubmit = useCallback(() => {
+ // Validate required fields
+ const newErrors: Record = {};
+
+ 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 = {};
+
+ 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 (
+
+
+
+
+
+ {triggerType.name} Configuration
+
+
+ No additional configuration required
+
+
+
+
+
+
+
+
+ Ready to Create Trigger!
+
+
+
+ This trigger type doesn't require additional configuration. You can create it directly.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Configure {triggerType.name}
+
+
+ {triggerType.description}
+
+
+
+
+
+
+
+ Trigger Configuration
+
+
+
+
+
+ Configure the settings for your {toolkit.name} trigger:
+
+
+
+ {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 (
+
+
+
+ {property.description && (
+
+ {property.description}
+
+ )}
+ {fieldError && (
+
{fieldError}
+ )}
+
+ );
+ }
+
+ return (
+
handleFieldChange(fieldName, value)}
+ isRequired={isRequired}
+ type={property.type === 'number' || property.type === 'integer' ? 'number' : 'text'}
+ variant="bordered"
+ description={property.description}
+ isInvalid={!!fieldError}
+ errorMessage={fieldError}
+ />
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx
new file mode 100644
index 00000000..8a0b05fa
--- /dev/null
+++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx
@@ -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;
+ onProjectConfigUpdated?: () => void;
+}
+
+type TriggerDeployment = z.infer;
+
+export function TriggersModal({
+ isOpen,
+ onClose,
+ projectId,
+ projectConfig,
+ onProjectConfigUpdated,
+}: TriggersModalProps) {
+ const [triggers, setTriggers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showCreateFlow, setShowCreateFlow] = useState(false);
+ const [selectedToolkit, setSelectedToolkit] = useState | null>(null);
+ const [selectedTriggerType, setSelectedTriggerType] = useState | null>(null);
+ const [showAuthModal, setShowAuthModal] = useState(false);
+ const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
+ const [deletingTrigger, setDeletingTrigger] = useState(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) => {
+ setSelectedToolkit(toolkit);
+ };
+
+ const handleBackToToolkitSelection = () => {
+ setSelectedToolkit(null);
+ setSelectedTriggerType(null);
+ setIsSubmittingTrigger(false);
+ };
+
+ const handleSelectTriggerType = (triggerType: z.infer) => {
+ 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) => {
+ 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 (
+
+
+ Loading triggers...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+
+ );
+ }
+
+ if (triggers.length === 0) {
+ return (
+
+
+
+ No triggers configured
+
+
+ Set up your first trigger to listen for events from your connected apps.
+
+
}
+ onPress={handleCreateNew}
+ >
+ Create your first trigger
+
+
+ );
+ }
+
+ return (
+
+
+
+ Active Triggers ({triggers.length})
+
+ }
+ onPress={handleCreateNew}
+ >
+ Create New Trigger
+
+
+
+
+ {triggers.map((trigger) => (
+
+
+
+
+ {trigger.triggerTypeSlug}
+
+
+ Created {new Date(trigger.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
Trigger ID: {trigger.triggerId}
+
Connected Account: {trigger.connectedAccountId}
+ {Object.keys(trigger.triggerConfig).length > 0 && (
+
+
Configuration:
+
+ {JSON.stringify(trigger.triggerConfig, null, 2)}
+
+
+ )}
+
+
+
+ ))}
+
+
+ );
+ };
+
+ 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 (
+
+ );
+ }
+ }
+
+ // If no toolkit selected, show toolkit selection
+ if (!selectedToolkit) {
+ return (
+
+
+
+ Select a Toolkit to Create Trigger
+
+
+
+
+
+
+ );
+ }
+
+ // If toolkit selected, show trigger types
+ return (
+
+
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Manage Triggers
+
+
+
+ {showCreateFlow ? renderCreateFlow() : renderTriggerList()}
+
+ {!showCreateFlow && (
+
+
+
+ )}
+
+
+
+ {/* Auth Modal */}
+ {selectedToolkit && (
+ setShowAuthModal(false)}
+ toolkitSlug={selectedToolkit.slug}
+ projectId={projectId}
+ onComplete={handleAuthComplete}
+ />
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx
index 55da3bc6..be9731d1 100644
--- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx
+++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx
@@ -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(projectConfig.name || '');
const [projectNameError, setProjectNameError] = useState(null);
@@ -1359,6 +1363,13 @@ export function WorkflowEditor({
>
Chat widget
+ }
+ onPress={onTriggersModalOpen}
+ >
+ Manage triggers
+
@@ -1647,6 +1658,15 @@ export function WorkflowEditor({
+
+ {/* Triggers Management Modal */}
+
);
diff --git a/apps/rowboat/app/projects/layout/components/sidebar.tsx b/apps/rowboat/app/projects/layout/components/sidebar.tsx
index 5f6cf324..300debcf 100644
--- a/apps/rowboat/app/projects/layout/components/sidebar.tsx
+++ b/apps/rowboat/app/projects/layout/components/sidebar.tsx
@@ -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',
diff --git a/apps/rowboat/app/scripts/jobs-worker.ts b/apps/rowboat/app/scripts/jobs-worker.ts
new file mode 100644
index 00000000..d441d2cb
--- /dev/null
+++ b/apps/rowboat/app/scripts/jobs-worker.ts
@@ -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('jobsWorker');
+ await jobsWorker.run();
+ } catch (error) {
+ console.error(`Unable to run jobs worker: ${error}`);
+ }
+})();
\ No newline at end of file
diff --git a/apps/rowboat/di/container.ts b/apps/rowboat/di/container.ts
index 247ab5cf..afb3f9d8 100644
--- a/apps/rowboat/di/container.ts
+++ b/apps/rowboat/di/container.ts
@@ -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(),
});
\ No newline at end of file
diff --git a/apps/rowboat/package-lock.json b/apps/rowboat/package-lock.json
index 55a54192..465e2391 100644
--- a/apps/rowboat/package-lock.json
+++ b/apps/rowboat/package-lock.json
@@ -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",
diff --git a/apps/rowboat/package.json b/apps/rowboat/package.json
index 75c9a017..170e6919 100644
--- a/apps/rowboat/package.json
+++ b/apps/rowboat/package.json
@@ -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",
diff --git a/apps/rowboat/src/application/repositories/composio-trigger-deployments.repository.interface.ts b/apps/rowboat/src/application/repositories/composio-trigger-deployments.repository.interface.ts
new file mode 100644
index 00000000..8aba0da1
--- /dev/null
+++ b/apps/rowboat/src/application/repositories/composio-trigger-deployments.repository.interface.ts
@@ -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): Promise>;
+
+ /**
+ * 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 | 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;
+
+ /**
+ * 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 | 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>>>;
+
+ /**
+ * 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>>>;
+
+ /**
+ * 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;
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/repositories/conversations.repository.interface.ts b/apps/rowboat/src/application/repositories/conversations.repository.interface.ts
index 12e98779..02ba52a4 100644
--- a/apps/rowboat/src/application/repositories/conversations.repository.interface.ts
+++ b/apps/rowboat/src/application/repositories/conversations.repository.interface.ts
@@ -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): Promise>;
+ create(data: z.infer): Promise>;
// get conversation
- getConversation(id: string): Promise | null>;
+ fetch(id: string): Promise | null>;
+
+ // list conversations for project
+ list(projectId: string, cursor?: string, limit?: number): Promise>>>;
// add turn data to conversation
// returns the created turn
diff --git a/apps/rowboat/src/application/repositories/jobs.repository.interface.ts b/apps/rowboat/src/application/repositories/jobs.repository.interface.ts
new file mode 100644
index 00000000..8717098f
--- /dev/null
+++ b/apps/rowboat/src/application/repositories/jobs.repository.interface.ts
@@ -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): Promise>;
+
+ /**
+ * 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 | 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 | 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>;
+
+ /**
+ * 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): Promise>;
+
+ /**
+ * 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;
+
+ /**
+ * 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>>>;
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/repositories/projects.repository.interface.ts b/apps/rowboat/src/application/repositories/projects.repository.interface.ts
new file mode 100644
index 00000000..e115120b
--- /dev/null
+++ b/apps/rowboat/src/application/repositories/projects.repository.interface.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+import { Project } from "@/src/entities/models/project";
+
+export interface IProjectsRepository {
+ fetch(id: string): Promise | null>;
+
+ deleteComposioConnectedAccount(projectId: string, toolkitSlug: string): Promise;
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/services/pub-sub.service.interface.ts b/apps/rowboat/src/application/services/pub-sub.service.interface.ts
new file mode 100644
index 00000000..72ad2e81
--- /dev/null
+++ b/apps/rowboat/src/application/services/pub-sub.service.interface.ts
@@ -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;
+}
+
+/**
+ * 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;
+
+ /**
+ * 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;
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case.ts b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case.ts
new file mode 100644
index 00000000..4088fd9b
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case.ts
@@ -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): Promise>;
+}
+
+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): Promise> {
+ // 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,
+ });
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case.ts b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case.ts
new file mode 100644
index 00000000..c854d60b
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case.ts
@@ -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): Promise;
+}
+
+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): Promise {
+ // 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);
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case.ts b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case.ts
new file mode 100644
index 00000000..d233b096
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case.ts
@@ -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): Promise>>>;
+}
+
+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): Promise>>> {
+ // 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);
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case.ts b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case.ts
new file mode 100644
index 00000000..f80a9f6d
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case.ts
@@ -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): Promise>>>;
+}
+
+export class ListComposioTriggerTypesUseCase implements IListComposioTriggerTypesUseCase {
+ async execute(request: z.infer): Promise>>> {
+ // 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,
+ };
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/use-cases/composio/delete-composio-connected-account.use-case.ts b/apps/rowboat/src/application/use-cases/composio/delete-composio-connected-account.use-case.ts
new file mode 100644
index 00000000..18fc2c3b
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/composio/delete-composio-connected-account.use-case.ts
@@ -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): Promise;
+}
+
+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): Promise {
+ // 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);
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case.ts b/apps/rowboat/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case.ts
new file mode 100644
index 00000000..6c8fb8ef
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case.ts
@@ -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): Promise;
+}
+
+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): Promise {
+ 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;
+ 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 = {
+ 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}`);
+ }
+}
diff --git a/apps/rowboat/src/application/use-cases/conversations/create-cached-turn.use-case.ts b/apps/rowboat/src/application/use-cases/conversations/create-cached-turn.use-case.ts
index e4612cec..3d7bdb3b 100644
--- a/apps/rowboat/src/application/use-cases/conversations/create-cached-turn.use-case.ts
+++ b/apps/rowboat/src/application/use-cases/conversations/create-cached-turn.use-case.ts
@@ -44,7 +44,7 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
async execute(data: z.infer): 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');
}
diff --git a/apps/rowboat/src/application/use-cases/conversations/create-conversation.use-case.ts b/apps/rowboat/src/application/use-cases/conversations/create-conversation.use-case.ts
index 2654c10a..7ff60313 100644
--- a/apps/rowboat/src/application/use-cases/conversations/create-conversation.use-case.ts
+++ b/apps/rowboat/src/application/use-cases/conversations/create-conversation.use-case.ts
@@ -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,
diff --git a/apps/rowboat/src/application/use-cases/conversations/fetch-cached-turn.use-case.ts b/apps/rowboat/src/application/use-cases/conversations/fetch-cached-turn.use-case.ts
index 836115b9..273fdab3 100644
--- a/apps/rowboat/src/application/use-cases/conversations/fetch-cached-turn.use-case.ts
+++ b/apps/rowboat/src/application/use-cases/conversations/fetch-cached-turn.use-case.ts
@@ -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');
}
diff --git a/apps/rowboat/src/application/use-cases/conversations/fetch-conversation.use-case.ts b/apps/rowboat/src/application/use-cases/conversations/fetch-conversation.use-case.ts
new file mode 100644
index 00000000..b6a5ed26
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/conversations/fetch-conversation.use-case.ts
@@ -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): Promise>;
+}
+
+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): Promise> {
+ // 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;
+ }
+}
diff --git a/apps/rowboat/src/application/use-cases/conversations/list-conversations.use-case.ts b/apps/rowboat/src/application/use-cases/conversations/list-conversations.use-case.ts
new file mode 100644
index 00000000..d3b69afa
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/conversations/list-conversations.use-case.ts
@@ -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): Promise>>>;
+}
+
+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): Promise>>> {
+ // 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);
+ }
+}
diff --git a/apps/rowboat/src/application/use-cases/conversations/run-conversation-turn.use-case.ts b/apps/rowboat/src/application/use-cases/conversations/run-conversation-turn.use-case.ts
index 515551f4..fa433d5e 100644
--- a/apps/rowboat/src/application/use-cases/conversations/run-conversation-turn.use-case.ts
+++ b/apps/rowboat/src/application/use-cases/conversations/run-conversation-turn.use-case.ts
@@ -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): AsyncGenerator, 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,
});
diff --git a/apps/rowboat/src/application/use-cases/jobs/fetch-job.use-case.ts b/apps/rowboat/src/application/use-cases/jobs/fetch-job.use-case.ts
new file mode 100644
index 00000000..0156bc64
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/jobs/fetch-job.use-case.ts
@@ -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): Promise>;
+}
+
+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): Promise> {
+ // 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;
+ }
+}
diff --git a/apps/rowboat/src/application/use-cases/jobs/list-jobs.use-case.ts b/apps/rowboat/src/application/use-cases/jobs/list-jobs.use-case.ts
new file mode 100644
index 00000000..746ea281
--- /dev/null
+++ b/apps/rowboat/src/application/use-cases/jobs/list-jobs.use-case.ts
@@ -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): Promise>>>;
+}
+
+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): Promise>>> {
+ // 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);
+ }
+}
diff --git a/apps/rowboat/src/application/workers/jobs.worker.ts b/apps/rowboat/src/application/workers/jobs.worker.ts
new file mode 100644
index 00000000..8f8807a9
--- /dev/null
+++ b/apps/rowboat/src/application/workers/jobs.worker.ts
@@ -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;
+ stop(): Promise;
+}
+
+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): Promise {
+ 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 | 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 {
+ 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 | 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/entities/common/paginated-list.ts b/apps/rowboat/src/entities/common/paginated-list.ts
new file mode 100644
index 00000000..52cdbc24
--- /dev/null
+++ b/apps/rowboat/src/entities/common/paginated-list.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const PaginatedList = (schema: T) => z.object({
+ items: z.array(schema),
+ nextCursor: z.string().nullable(),
+});
\ No newline at end of file
diff --git a/apps/rowboat/src/entities/errors/job-errors.ts b/apps/rowboat/src/entities/errors/job-errors.ts
new file mode 100644
index 00000000..c513461c
--- /dev/null
+++ b/apps/rowboat/src/entities/errors/job-errors.ts
@@ -0,0 +1,5 @@
+export class JobAcquisitionError extends Error {
+ constructor(message?: string, options?: ErrorOptions) {
+ super(message, options);
+ }
+}
diff --git a/apps/rowboat/src/entities/models/composio-trigger-deployment.ts b/apps/rowboat/src/entities/models/composio-trigger-deployment.ts
new file mode 100644
index 00000000..b6ccc294
--- /dev/null
+++ b/apps/rowboat/src/entities/models/composio-trigger-deployment.ts
@@ -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(),
+});
\ No newline at end of file
diff --git a/apps/rowboat/src/entities/models/composio-trigger-type.ts b/apps/rowboat/src/entities/models/composio-trigger-type.ts
new file mode 100644
index 00000000..4be5c34f
--- /dev/null
+++ b/apps/rowboat/src/entities/models/composio-trigger-type.ts
@@ -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(),
+ }),
+});
\ No newline at end of file
diff --git a/apps/rowboat/src/entities/models/job.ts b/apps/rowboat/src/entities/models/job.ts
new file mode 100644
index 00000000..95d22d48
--- /dev/null
+++ b/apps/rowboat/src/entities/models/job.ts
@@ -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(),
+});
\ No newline at end of file
diff --git a/apps/rowboat/src/entities/models/project.ts b/apps/rowboat/src/entities/models/project.ts
new file mode 100644
index 00000000..c5c2e65a
--- /dev/null
+++ b/apps/rowboat/src/entities/models/project.ts
@@ -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(),
+ });
\ No newline at end of file
diff --git a/apps/rowboat/src/entities/models/turn.ts b/apps/rowboat/src/entities/models/turn.ts
index e8692475..80d225fa 100644
--- a/apps/rowboat/src/entities/models/turn.ts
+++ b/apps/rowboat/src/entities/models/turn.ts
@@ -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(),
diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.composio-trigger-deployments.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.composio-trigger-deployments.repository.ts
new file mode 100644
index 00000000..5619650a
--- /dev/null
+++ b/apps/rowboat/src/infrastructure/repositories/mongodb.composio-trigger-deployments.repository.ts
@@ -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>("composio_trigger_deployments");
+
+ constructor() {
+ // Create indexes for efficient querying
+ this.createIndexes();
+ }
+
+ /**
+ * Creates the necessary indexes for efficient querying.
+ */
+ private async createIndexes(): Promise {
+ 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): Promise> {
+ 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 | 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 {
+ 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 | 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>>> {
+ 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>>> {
+ 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 {
+ const result = await this.collection.deleteMany({
+ connectedAccountId,
+ });
+
+ return result.deletedCount;
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.conversations.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.conversations.repository.ts
index 99f08390..a988839d 100644
--- a/apps/rowboat/src/infrastructure/repositories/mongodb.conversations.repository.ts
+++ b/apps/rowboat/src/infrastructure/repositories/mongodb.conversations.repository.ts
@@ -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>("conversations");
- async createConversation(data: z.infer): Promise> {
+ async create(data: z.infer): Promise> {
const now = new Date();
const _id = new ObjectId();
@@ -35,7 +36,7 @@ export class MongoDBConversationsRepository implements IConversationsRepository
};
}
- async getConversation(id: string): Promise | null> {
+ async fetch(id: string): Promise | 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>>> {
+ 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 & { _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,
+ };
+ }
}
\ No newline at end of file
diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.jobs.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.jobs.repository.ts
new file mode 100644
index 00000000..057dc69e
--- /dev/null
+++ b/apps/rowboat/src/infrastructure/repositories/mongodb.jobs.repository.ts
@@ -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>("jobs");
+
+ /**
+ * Creates a new job in the system.
+ */
+ async create(data: z.infer): Promise> {
+ const now = new Date().toISOString();
+ const _id = new ObjectId();
+
+ const doc: z.infer = {
+ ...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 | 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 | 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> {
+ 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): Promise> {
+ 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 {
+ 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>>> {
+ 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 & { _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,
+ };
+ }
+}
diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.projects.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.projects.repository.ts
new file mode 100644
index 00000000..3b3d1e3e
--- /dev/null
+++ b/apps/rowboat/src/infrastructure/repositories/mongodb.projects.repository.ts
@@ -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 | 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 {
+ const result = await projectsCollection.updateOne({ _id: projectId }, { $unset: { [`composioConnectedAccounts.${toolkitSlug}`]: "" } });
+ return result.modifiedCount > 0;
+ }
+}
\ No newline at end of file
diff --git a/apps/rowboat/src/infrastructure/services/redis.pub-sub.service.ts b/apps/rowboat/src/infrastructure/services/redis.pub-sub.service.ts
new file mode 100644
index 00000000..4790f22a
--- /dev/null
+++ b/apps/rowboat/src/infrastructure/services/redis.pub-sub.service.ts
@@ -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 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 {
+ 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 {
+ 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 => {
+ 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 {
+ 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}`);
+ }
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller.ts b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller.ts
new file mode 100644
index 00000000..275fdb32
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller.ts
@@ -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): Promise>;
+}
+
+export class CreateComposioTriggerDeploymentController implements ICreateComposioTriggerDeploymentController {
+ private readonly createComposioTriggerDeploymentUseCase: ICreateComposioTriggerDeploymentUseCase;
+
+ constructor({
+ createComposioTriggerDeploymentUseCase,
+ }: {
+ createComposioTriggerDeploymentUseCase: ICreateComposioTriggerDeploymentUseCase,
+ }) {
+ this.createComposioTriggerDeploymentUseCase = createComposioTriggerDeploymentUseCase;
+ }
+
+ async execute(request: z.infer): Promise> {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller.ts b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller.ts
new file mode 100644
index 00000000..f392f58e
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller.ts
@@ -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): Promise;
+}
+
+export class DeleteComposioTriggerDeploymentController implements IDeleteComposioTriggerDeploymentController {
+ private readonly deleteComposioTriggerDeploymentUseCase: IDeleteComposioTriggerDeploymentUseCase;
+
+ constructor({
+ deleteComposioTriggerDeploymentUseCase,
+ }: {
+ deleteComposioTriggerDeploymentUseCase: IDeleteComposioTriggerDeploymentUseCase,
+ }) {
+ this.deleteComposioTriggerDeploymentUseCase = deleteComposioTriggerDeploymentUseCase;
+ }
+
+ async execute(request: z.infer): Promise {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller.ts b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller.ts
new file mode 100644
index 00000000..a54b47bb
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller.ts
@@ -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): Promise>>>;
+}
+
+export class ListComposioTriggerDeploymentsController implements IListComposioTriggerDeploymentsController {
+ private readonly listComposioTriggerDeploymentsUseCase: IListComposioTriggerDeploymentsUseCase;
+
+ constructor({
+ listComposioTriggerDeploymentsUseCase,
+ }: {
+ listComposioTriggerDeploymentsUseCase: IListComposioTriggerDeploymentsUseCase,
+ }) {
+ this.listComposioTriggerDeploymentsUseCase = listComposioTriggerDeploymentsUseCase;
+ }
+
+ async execute(request: z.infer): Promise>>> {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller.ts b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller.ts
new file mode 100644
index 00000000..67470108
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller.ts
@@ -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): Promise>>>;
+}
+
+export class ListComposioTriggerTypesController implements IListComposioTriggerTypesController {
+ private readonly listComposioTriggerTypesUseCase: IListComposioTriggerTypesUseCase;
+
+ constructor({
+ listComposioTriggerTypesUseCase,
+ }: {
+ listComposioTriggerTypesUseCase: IListComposioTriggerTypesUseCase,
+ }) {
+ this.listComposioTriggerTypesUseCase = listComposioTriggerTypesUseCase;
+ }
+
+ async execute(request: z.infer): Promise>>> {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/composio/delete-composio-connected-account.controller.ts b/apps/rowboat/src/interface-adapters/controllers/composio/delete-composio-connected-account.controller.ts
new file mode 100644
index 00000000..50651e0a
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/composio/delete-composio-connected-account.controller.ts
@@ -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): Promise;
+}
+
+export class DeleteComposioConnectedAccountController implements IDeleteComposioConnectedAccountController {
+ private readonly deleteComposioConnectedAccountUseCase: IDeleteComposioConnectedAccountUseCase;
+
+ constructor({
+ deleteComposioConnectedAccountUseCase,
+ }: {
+ deleteComposioConnectedAccountUseCase: IDeleteComposioConnectedAccountUseCase,
+ }) {
+ this.deleteComposioConnectedAccountUseCase = deleteComposioConnectedAccountUseCase;
+ }
+
+ async execute(request: z.infer): Promise {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller.ts b/apps/rowboat/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller.ts
new file mode 100644
index 00000000..45f1307d
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller.ts
@@ -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): Promise;
+}
+
+export class HandleComposioWebhookRequestController implements IHandleComposioWebhookRequestController {
+ private readonly handleCompsioWebhookRequestUseCase: IHandleCompsioWebhookRequestUseCase;
+
+ constructor({
+ handleCompsioWebhookRequestUseCase,
+ }: {
+ handleCompsioWebhookRequestUseCase: IHandleCompsioWebhookRequestUseCase,
+ }) {
+ this.handleCompsioWebhookRequestUseCase = handleCompsioWebhookRequestUseCase;
+ }
+
+ async execute(request: z.infer): Promise {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/conversations/fetch-conversation.controller.ts b/apps/rowboat/src/interface-adapters/controllers/conversations/fetch-conversation.controller.ts
new file mode 100644
index 00000000..61833e00
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/conversations/fetch-conversation.controller.ts
@@ -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): Promise>;
+}
+
+export class FetchConversationController implements IFetchConversationController {
+ private readonly fetchConversationUseCase: IFetchConversationUseCase;
+
+ constructor({
+ fetchConversationUseCase,
+ }: {
+ fetchConversationUseCase: IFetchConversationUseCase,
+ }) {
+ this.fetchConversationUseCase = fetchConversationUseCase;
+ }
+
+ async execute(request: z.infer): Promise> {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/conversations/list-conversations.controller.ts b/apps/rowboat/src/interface-adapters/controllers/conversations/list-conversations.controller.ts
new file mode 100644
index 00000000..5f37c63e
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/conversations/list-conversations.controller.ts
@@ -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): Promise>>>;
+}
+
+export class ListConversationsController implements IListConversationsController {
+ private readonly listConversationsUseCase: IListConversationsUseCase;
+
+ constructor({
+ listConversationsUseCase,
+ }: {
+ listConversationsUseCase: IListConversationsUseCase,
+ }) {
+ this.listConversationsUseCase = listConversationsUseCase;
+ }
+
+ async execute(request: z.infer): Promise>>> {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/conversations/run-cached-turn.controller.ts b/apps/rowboat/src/interface-adapters/controllers/conversations/run-cached-turn.controller.ts
index ce551d9d..935437c7 100644
--- a/apps/rowboat/src/interface-adapters/controllers/conversations/run-cached-turn.controller.ts
+++ b/apps/rowboat/src/interface-adapters/controllers/conversations/run-cached-turn.controller.ts
@@ -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,
});
}
diff --git a/apps/rowboat/src/interface-adapters/controllers/conversations/run-turn.controller.ts b/apps/rowboat/src/interface-adapters/controllers/conversations/run-turn.controller.ts
index 8f5b01ff..71ab496d 100644
--- a/apps/rowboat/src/interface-adapters/controllers/conversations/run-turn.controller.ts
+++ b/apps/rowboat/src/interface-adapters/controllers/conversations/run-turn.controller.ts
@@ -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,
});
diff --git a/apps/rowboat/src/interface-adapters/controllers/jobs/fetch-job.controller.ts b/apps/rowboat/src/interface-adapters/controllers/jobs/fetch-job.controller.ts
new file mode 100644
index 00000000..7453d0b2
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/jobs/fetch-job.controller.ts
@@ -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): Promise>;
+}
+
+export class FetchJobController implements IFetchJobController {
+ private readonly fetchJobUseCase: IFetchJobUseCase;
+
+ constructor({
+ fetchJobUseCase,
+ }: {
+ fetchJobUseCase: IFetchJobUseCase,
+ }) {
+ this.fetchJobUseCase = fetchJobUseCase;
+ }
+
+ async execute(request: z.infer): Promise> {
+ // 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,
+ });
+ }
+}
diff --git a/apps/rowboat/src/interface-adapters/controllers/jobs/list-jobs.controller.ts b/apps/rowboat/src/interface-adapters/controllers/jobs/list-jobs.controller.ts
new file mode 100644
index 00000000..af9dcdee
--- /dev/null
+++ b/apps/rowboat/src/interface-adapters/controllers/jobs/list-jobs.controller.ts
@@ -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): Promise>>>;
+}
+
+export class ListJobsController implements IListJobsController {
+ private readonly listJobsUseCase: IListJobsUseCase;
+
+ constructor({
+ listJobsUseCase,
+ }: {
+ listJobsUseCase: IListJobsUseCase,
+ }) {
+ this.listJobsUseCase = listJobsUseCase;
+ }
+
+ async execute(request: z.infer): Promise>>> {
+ // 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,
+ });
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index b73cb164..2426656d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -218,6 +218,28 @@ services:
- BILLING_API_KEY=${BILLING_API_KEY}
restart: unless-stopped
+ jobs-worker:
+ build:
+ context: ./apps/rowboat
+ dockerfile: scripts.Dockerfile
+ command: ["npm", "run", "jobs-worker"]
+ environment:
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - MONGODB_CONNECTION_STRING=mongodb://mongo:27017/rowboat
+ - REDIS_URL=redis://redis:6379
+ - QDRANT_URL=http://qdrant:6333
+ - QDRANT_API_KEY=${QDRANT_API_KEY}
+ - PROVIDER_API_KEY=${PROVIDER_API_KEY}
+ - PROVIDER_BASE_URL=${PROVIDER_BASE_URL}
+ - PROVIDER_DEFAULT_MODEL=${PROVIDER_DEFAULT_MODEL}
+ - PROVIDER_COPILOT_MODEL=${PROVIDER_COPILOT_MODEL}
+ - USE_BILLING=${USE_BILLING}
+ - BILLING_API_URL=${BILLING_API_URL}
+ - BILLING_API_KEY=${BILLING_API_KEY}
+ - USE_COMPOSIO_TOOLS=${USE_COMPOSIO_TOOLS}
+ - COMPOSIO_API_KEY=${COMPOSIO_API_KEY}
+ restart: unless-stopped
+
# chat_widget:
# build:
# context: ./apps/experimental/chat_widget