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...
+
+ )} + {!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...
+
+ )} + {!loading && items.length === 0 && ( +

No conversations yet.

+ )} + {!loading && items.length > 0 && ( +
+ {Object.entries(sections).map(([label, group]) => ( + group.length > 0 ? ( +
+
{label}
+
+ + + + + + + + + {group.map((c) => ( + + + + + ))} + +
ConversationCreated
+ + {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...
+
+ )} + {!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 && ( +
+
Job not found.
+
+ )} +
+
+
+ ); +} 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...
+
+ )} + {!loading && items.length === 0 && ( +

No jobs yet.

+ )} + {!loading && items.length > 0 && ( +
+ {Object.entries(sections).map(([label, group]) => ( + group.length > 0 ? ( +
+
{label}
+
+ + + + + + + + + + + {group.map((job) => ( + + + + + + + ))} + +
JobStatusReasonCreated
+ + {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 && ( +
+ +
+ )} +
+ )} +
+ ); +} \ 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. +

+ +
+ ); + } + + return ( +
+
+

+ Active Triggers ({triggers.length}) +

+ +
+ +
+ {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