mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
add composio triggers (#192)
This commit is contained in:
parent
5e706f0684
commit
3552302f4a
72 changed files with 4887 additions and 111 deletions
|
|
@ -22,6 +22,19 @@ import {
|
|||
import { ComposioConnectedAccount } from "@/app/lib/types/project_types";
|
||||
import { getProjectConfig, projectAuthCheck } from "./project_actions";
|
||||
import { projectsCollection } from "../lib/mongodb";
|
||||
import { container } from "@/di/container";
|
||||
import { ICreateComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller";
|
||||
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
|
||||
import { IDeleteComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller";
|
||||
import { IListComposioTriggerTypesController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller";
|
||||
import { IDeleteComposioConnectedAccountController } from "@/src/interface-adapters/controllers/composio/delete-composio-connected-account.controller";
|
||||
import { authCheck } from "./auth_actions";
|
||||
|
||||
const createComposioTriggerDeploymentController = container.resolve<ICreateComposioTriggerDeploymentController>("createComposioTriggerDeploymentController");
|
||||
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>("listComposioTriggerDeploymentsController");
|
||||
const deleteComposioTriggerDeploymentController = container.resolve<IDeleteComposioTriggerDeploymentController>("deleteComposioTriggerDeploymentController");
|
||||
const listComposioTriggerTypesController = container.resolve<IListComposioTriggerTypesController>("listComposioTriggerTypesController");
|
||||
const deleteComposioConnectedAccountController = container.resolve<IDeleteComposioConnectedAccountController>("deleteComposioConnectedAccountController");
|
||||
|
||||
const ZCreateCustomConnectedAccountRequest = z.object({
|
||||
toolkitSlug: z.string(),
|
||||
|
|
@ -191,29 +204,77 @@ export async function syncConnectedAccount(projectId: string, toolkitSlug: strin
|
|||
}
|
||||
|
||||
export async function deleteConnectedAccount(projectId: string, toolkitSlug: string, connectedAccountId: string): Promise<boolean> {
|
||||
await projectAuthCheck(projectId);
|
||||
const user = await authCheck();
|
||||
|
||||
// ensure that the connected account belongs to this project
|
||||
const project = await getProjectConfig(projectId);
|
||||
const account = project.composioConnectedAccounts?.[toolkitSlug];
|
||||
if (!account || account.id !== connectedAccountId) {
|
||||
throw new Error(`Connected account ${connectedAccountId} not found in project ${projectId} for toolkit ${toolkitSlug}`);
|
||||
}
|
||||
|
||||
// delete the connected account
|
||||
await libDeleteConnectedAccount(connectedAccountId);
|
||||
|
||||
// get auth config data
|
||||
const authConfig = await libGetAuthConfig(account.authConfigId);
|
||||
|
||||
// delete the auth config if it is NOT managed by composio
|
||||
if (!authConfig.is_composio_managed) {
|
||||
await libDeleteAuthConfig(account.authConfigId);
|
||||
}
|
||||
|
||||
// update project with deleted connected account
|
||||
const key = `composioConnectedAccounts.${toolkitSlug}`;
|
||||
await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } });
|
||||
await deleteComposioConnectedAccountController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId,
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listComposioTriggerTypes(toolkitSlug: string, cursor?: string) {
|
||||
await authCheck();
|
||||
|
||||
return await listComposioTriggerTypesController.execute({
|
||||
toolkitSlug,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createComposioTriggerDeployment(request: {
|
||||
projectId: string,
|
||||
toolkitSlug: string,
|
||||
triggerTypeSlug: string,
|
||||
connectedAccountId: string,
|
||||
triggerConfig?: Record<string, unknown>,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
// create trigger deployment
|
||||
return await createComposioTriggerDeploymentController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
data: {
|
||||
projectId: request.projectId,
|
||||
toolkitSlug: request.toolkitSlug,
|
||||
triggerTypeSlug: request.triggerTypeSlug,
|
||||
connectedAccountId: request.connectedAccountId,
|
||||
triggerConfig: request.triggerConfig ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listComposioTriggerDeployments(request: {
|
||||
projectId: string,
|
||||
cursor?: string,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
// list trigger deployments
|
||||
return await listComposioTriggerDeploymentsController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId: request.projectId,
|
||||
cursor: request.cursor,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteComposioTriggerDeployment(request: {
|
||||
projectId: string,
|
||||
deploymentId: string,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
// delete trigger deployment
|
||||
return await deleteComposioTriggerDeploymentController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId: request.projectId,
|
||||
deploymentId: request.deploymentId,
|
||||
});
|
||||
}
|
||||
37
apps/rowboat/app/actions/conversation_actions.ts
Normal file
37
apps/rowboat/app/actions/conversation_actions.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use server";
|
||||
|
||||
import { container } from "@/di/container";
|
||||
import { IListConversationsController } from "@/src/interface-adapters/controllers/conversations/list-conversations.controller";
|
||||
import { IFetchConversationController } from "@/src/interface-adapters/controllers/conversations/fetch-conversation.controller";
|
||||
import { authCheck } from "./auth_actions";
|
||||
|
||||
const listConversationsController = container.resolve<IListConversationsController>('listConversationsController');
|
||||
const fetchConversationController = container.resolve<IFetchConversationController>('fetchConversationController');
|
||||
|
||||
export async function listConversations(request: {
|
||||
projectId: string,
|
||||
cursor?: string,
|
||||
limit?: number,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
return await listConversationsController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId: request.projectId,
|
||||
cursor: request.cursor,
|
||||
limit: request.limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchConversation(request: {
|
||||
conversationId: string,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
return await fetchConversationController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
conversationId: request.conversationId,
|
||||
});
|
||||
}
|
||||
37
apps/rowboat/app/actions/job_actions.ts
Normal file
37
apps/rowboat/app/actions/job_actions.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use server";
|
||||
|
||||
import { container } from "@/di/container";
|
||||
import { IListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
|
||||
import { IFetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
|
||||
import { authCheck } from "./auth_actions";
|
||||
|
||||
const listJobsController = container.resolve<IListJobsController>('listJobsController');
|
||||
const fetchJobController = container.resolve<IFetchJobController>('fetchJobController');
|
||||
|
||||
export async function listJobs(request: {
|
||||
projectId: string,
|
||||
cursor?: string,
|
||||
limit?: number,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
return await listJobsController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
projectId: request.projectId,
|
||||
cursor: request.cursor,
|
||||
limit: request.limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchJob(request: {
|
||||
jobId: string,
|
||||
}) {
|
||||
const user = await authCheck();
|
||||
|
||||
return await fetchJobController.execute({
|
||||
caller: 'user',
|
||||
userId: user._id,
|
||||
jobId: request.jobId,
|
||||
});
|
||||
}
|
||||
69
apps/rowboat/app/api/composio/webhook/route.ts
Normal file
69
apps/rowboat/app/api/composio/webhook/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import { container } from "@/di/container";
|
||||
import { IHandleComposioWebhookRequestController } from "@/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const handleComposioWebhookRequestController = container.resolve<IHandleComposioWebhookRequestController>("handleComposioWebhookRequestController");
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const id = nanoid();
|
||||
const logger = new PrefixLogger(`composio-webhook-[${id}]`);
|
||||
const payload = await request.text();
|
||||
const headers = Object.fromEntries(request.headers.entries());
|
||||
logger.log('received event', JSON.stringify(headers));
|
||||
|
||||
// handle webhook
|
||||
try {
|
||||
await handleComposioWebhookRequestController.execute({
|
||||
headers,
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log('Error handling composio webhook', error);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
"type": "slack_receive_message",
|
||||
"timestamp": "2025-08-06T01:49:46.008Z",
|
||||
"data": {
|
||||
"bot_id": null,
|
||||
"channel": "C08PTQKM2DS",
|
||||
"channel_type": "channel",
|
||||
"team_id": null,
|
||||
"text": "test",
|
||||
"ts": "1754444983.699449",
|
||||
"user": "U077XPW36V9",
|
||||
"connection_id": "551d86b3-44e3-4c62-b996-44648ccf77b3",
|
||||
"connection_nano_id": "ca_2n0cZnluJ1qc",
|
||||
"trigger_nano_id": "ti_dU7LJMfP5KSr",
|
||||
"trigger_id": "ec96b753-c745-4f37-b5d8-82a35ce0fa0b",
|
||||
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"type": "github_issue_added_event",
|
||||
"timestamp": "2025-08-06T02:00:13.680Z",
|
||||
"data": {
|
||||
"action": "opened",
|
||||
"createdAt": "2025-08-06T02:00:10Z",
|
||||
"createdBy": "ramnique",
|
||||
"description": "this is a test issue",
|
||||
"issue_id": 3294929549,
|
||||
"number": 1,
|
||||
"title": "test issue",
|
||||
"url": "https://github.com/ramnique/stack-reload-bug/issues/1",
|
||||
"connection_id": "06d7c6b9-bd41-4ce7-a6b4-b17a65315c99",
|
||||
"connection_nano_id": "ca_HmQ-SSOdxUEu",
|
||||
"trigger_nano_id": "ti_IjLPi4O0d4xo",
|
||||
"trigger_id": "ccbf3ad3-442b-491c-a1c5-e23f8b606592",
|
||||
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
138
apps/rowboat/app/lib/components/message-display.tsx
Normal file
138
apps/rowboat/app/lib/components/message-display.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client';
|
||||
|
||||
import { z } from "zod";
|
||||
import { Message } from "@/app/lib/types/types";
|
||||
import Link from "next/link";
|
||||
|
||||
function ToolCallDisplay({ toolCall }: { toolCall: any }) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
TOOL CALL: {toolCall.function.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
ID: {toolCall.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 font-mono">
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Arguments:</span>
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700">
|
||||
{toolCall.function.arguments}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageDisplay({ message, index }: { message: z.infer<typeof Message>; index: number }) {
|
||||
const isUser = 'role' in message && message.role === 'user';
|
||||
const isAssistant = 'role' in message && message.role === 'assistant';
|
||||
const isSystem = 'role' in message && message.role === 'system';
|
||||
const isTool = 'role' in message && message.role === 'tool';
|
||||
|
||||
// Check if assistant message is internal
|
||||
const isInternal = isAssistant && 'responseType' in message && message.responseType === 'internal';
|
||||
|
||||
const getBubbleStyle = () => {
|
||||
if (isUser) {
|
||||
return 'ml-auto max-w-[80%] bg-blue-100 text-blue-900 border border-blue-200 rounded-2xl rounded-br-md';
|
||||
} else if (isAssistant) {
|
||||
if (isInternal) {
|
||||
return 'mr-auto max-w-[80%] bg-gray-50 text-gray-700 border border-dotted border-gray-300 rounded-2xl rounded-bl-md';
|
||||
} else {
|
||||
return 'mr-auto max-w-[80%] bg-green-100 text-green-900 border border-green-200 rounded-2xl rounded-bl-md';
|
||||
}
|
||||
} else if (isSystem) {
|
||||
return 'mx-auto max-w-[90%] bg-yellow-100 text-yellow-900 border border-yellow-200 rounded-2xl';
|
||||
} else if (isTool) {
|
||||
return 'mr-auto max-w-[80%] bg-purple-100 text-purple-900 border border-purple-200 rounded-2xl rounded-bl-md';
|
||||
}
|
||||
return 'mx-auto max-w-[80%] bg-gray-100 text-gray-900 border border-gray-200 rounded-2xl';
|
||||
};
|
||||
|
||||
const getRoleLabel = () => {
|
||||
if ('role' in message) {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return 'USER';
|
||||
case 'assistant':
|
||||
const baseLabel = 'agentName' in message && message.agentName ? `ASSISTANT (${message.agentName})` : 'ASSISTANT';
|
||||
return isInternal ? `${baseLabel} [INTERNAL]` : baseLabel;
|
||||
case 'system':
|
||||
return 'SYSTEM';
|
||||
case 'tool':
|
||||
return 'toolName' in message ? `TOOL (${message.toolName})` : 'TOOL';
|
||||
default:
|
||||
return (message as any).role?.toUpperCase() || 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
};
|
||||
|
||||
const getMessageContent = () => {
|
||||
if ('content' in message && message.content) {
|
||||
return message.content;
|
||||
}
|
||||
return '[No content]';
|
||||
};
|
||||
|
||||
const getTimestamp = () => {
|
||||
if ('timestamp' in message && message.timestamp) {
|
||||
return new Date(message.timestamp).toLocaleTimeString();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const timestamp = getTimestamp();
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
|
||||
<div className={`${getBubbleStyle()} p-3 shadow-sm`}>
|
||||
{/* Message Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold opacity-90">
|
||||
{getRoleLabel()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{timestamp && (
|
||||
<span className="text-xs opacity-75">
|
||||
{timestamp}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs opacity-75">
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className="text-sm">
|
||||
{isTool ? (
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono whitespace-pre-wrap">
|
||||
{getMessageContent()}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{getMessageContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool Calls Display */}
|
||||
{isAssistant && 'toolCalls' in message && message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-xs font-semibold opacity-90 border-t border-current/20 pt-2">
|
||||
TOOL CALLS ({message.toolCalls.length})
|
||||
</div>
|
||||
{message.toolCalls.map((toolCall, toolIndex) => (
|
||||
<ToolCallDisplay key={toolCall.id || toolIndex} toolCall={toolCall} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { z } from "zod";
|
||||
import { PrefixLogger } from "../utils";
|
||||
import { Composio } from "@composio/core";
|
||||
|
||||
const BASE_URL = 'https://backend.composio.dev/api/v3';
|
||||
const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || "";
|
||||
const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || "test";
|
||||
export const composio = new Composio({
|
||||
apiKey: COMPOSIO_API_KEY,
|
||||
});
|
||||
|
||||
export const ZAuthScheme = z.enum([
|
||||
'API_KEY',
|
||||
|
|
@ -27,14 +31,17 @@ export const ZConnectedAccountStatus = z.enum([
|
|||
'INACTIVE',
|
||||
]);
|
||||
|
||||
const ZToolkitMeta = z.object({
|
||||
description: z.string(),
|
||||
logo: z.string(),
|
||||
tools_count: z.number(),
|
||||
triggers_count: z.number(),
|
||||
});
|
||||
|
||||
export const ZToolkit = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
meta: z.object({
|
||||
description: z.string(),
|
||||
logo: z.string(),
|
||||
tools_count: z.number(),
|
||||
}),
|
||||
meta: ZToolkitMeta,
|
||||
no_auth: z.boolean(),
|
||||
auth_schemes: z.array(ZAuthScheme),
|
||||
composio_managed_auth_schemes: z.array(ZAuthScheme),
|
||||
|
|
@ -53,6 +60,7 @@ export const ZGetToolkitResponse = z.object({
|
|||
slug: z.string(),
|
||||
name: z.string(),
|
||||
composio_managed_auth_schemes: z.array(ZAuthScheme),
|
||||
meta: ZToolkitMeta,
|
||||
auth_config_details: z.array(z.object({
|
||||
name: z.string(),
|
||||
mode: ZAuthScheme,
|
||||
|
|
@ -217,6 +225,23 @@ export const ZDeleteOperationResponse = z.object({
|
|||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZTriggerType = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
logo: z.string(),
|
||||
}),
|
||||
config: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.any()),
|
||||
required: z.array(z.string()).optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
|
||||
items: z.array(schema),
|
||||
next_cursor: z.string().nullable(),
|
||||
|
|
@ -415,4 +440,17 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
|
|||
return await composioApiCall(ZDeleteOperationResponse, url.toString(), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTriggersTypes(toolkitSlug: string, cursor?: string): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTriggerType>>>> {
|
||||
const url = new URL(`${BASE_URL}/triggers_types`);
|
||||
|
||||
// set params
|
||||
url.searchParams.set("toolkit_slugs", toolkitSlug);
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
// fetch
|
||||
return composioApiCall(ZListResponse(ZTriggerType), url.toString());
|
||||
}
|
||||
|
|
@ -11,9 +11,8 @@ import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent";
|
|||
import { COPILOT_INSTRUCTIONS_MULTI_AGENT } from "./copilot_multi_agent";
|
||||
import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1";
|
||||
import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow";
|
||||
import { Composio } from '@composio/core';
|
||||
import { USE_COMPOSIO_TOOLS } from "../feature_flags";
|
||||
import { getTool } from "../composio/composio";
|
||||
import { composio, getTool } from "../composio/composio";
|
||||
|
||||
const PROVIDER_API_KEY = process.env.PROVIDER_API_KEY || process.env.OPENAI_API_KEY || '';
|
||||
const PROVIDER_BASE_URL = process.env.PROVIDER_BASE_URL || undefined;
|
||||
|
|
@ -103,8 +102,6 @@ async function searchRelevantTools(query: string): Promise<string> {
|
|||
return 'No tools found!';
|
||||
}
|
||||
|
||||
const composio = new Composio();
|
||||
|
||||
// Search for relevant tool slugs
|
||||
logger.log('searching for relevant tools...');
|
||||
const searchResult = await composio.tools.execute('COMPOSIO_SEARCH_TOOLS', {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { ConversationView } from "../components/conversation-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Conversation",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string, conversationId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <ConversationView projectId={params.projectId} conversationId={params.conversationId} />;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { fetchConversation } from "@/app/actions/conversation_actions";
|
||||
import { Conversation } from "@/src/entities/models/conversation";
|
||||
import { Turn } from "@/src/entities/models/turn";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { MessageDisplay } from "../../../../lib/components/message-display";
|
||||
|
||||
function TurnReason({ reason }: { reason: z.infer<typeof Turn>['reason'] }) {
|
||||
const getReasonDisplay = () => {
|
||||
switch (reason.type) {
|
||||
case 'chat':
|
||||
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
|
||||
case 'api':
|
||||
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
|
||||
case 'job':
|
||||
return { label: `JOB: ${reason.jobId}`, color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' };
|
||||
default:
|
||||
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
|
||||
}
|
||||
};
|
||||
|
||||
const { label, color } = getReasonDisplay();
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnReasonWithLink({ reason, projectId }: { reason: z.infer<typeof Turn>['reason']; projectId: string }) {
|
||||
const getReasonDisplay = () => {
|
||||
switch (reason.type) {
|
||||
case 'chat':
|
||||
return { label: 'CHAT', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' };
|
||||
case 'api':
|
||||
return { label: 'API', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' };
|
||||
case 'job':
|
||||
return {
|
||||
label: `JOB: ${reason.jobId}`,
|
||||
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
isJob: true,
|
||||
jobId: reason.jobId
|
||||
};
|
||||
default:
|
||||
return { label: 'UNKNOWN', color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300' };
|
||||
}
|
||||
};
|
||||
|
||||
const { label, color, isJob, jobId } = getReasonDisplay();
|
||||
|
||||
if (isJob && jobId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${jobId}`}
|
||||
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color} hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-mono font-medium ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnContainer({ turn, index, projectId }: { turn: z.infer<typeof Turn>; index: number; projectId: string }) {
|
||||
return (
|
||||
<div id={`turn-${turn.id}`} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Turn Header */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono font-semibold text-gray-700 dark:text-gray-300">
|
||||
TURN #{index + 1}
|
||||
</span>
|
||||
<TurnReasonWithLink reason={turn.reason} projectId={projectId} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{new Date(turn.createdAt).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turn Content */}
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{/* Input Messages */}
|
||||
{turn.input.messages && turn.input.messages.length > 0 && (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
||||
Input Messages ({turn.input.messages.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{turn.input.messages.map((message, msgIndex) => (
|
||||
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Messages */}
|
||||
{turn.output && turn.output.length > 0 && (
|
||||
<div className="p-4">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
||||
Output Messages ({turn.output.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{turn.output.map((message, msgIndex) => (
|
||||
<MessageDisplay key={`output-${msgIndex}`} message={message} index={msgIndex} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{turn.error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/10 border-l-4 border-red-500">
|
||||
<div className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1 uppercase tracking-wide">
|
||||
Error
|
||||
</div>
|
||||
<div className="text-sm text-red-700 dark:text-red-300 font-mono">
|
||||
{turn.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationView({ projectId, conversationId }: { projectId: string; conversationId: string; }) {
|
||||
const [conversation, setConversation] = useState<z.infer<typeof Conversation> | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchConversation({ conversationId });
|
||||
if (ignore) return;
|
||||
setConversation(res);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [conversationId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!conversation) return 'Conversation';
|
||||
return `Conversation ${conversation.id}`;
|
||||
}, [conversation]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
|
||||
rightActions={<div className="flex items-center gap-3"></div>}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && conversation && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Conversation Metadata */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{conversation.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(conversation.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{conversation.updatedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(conversation.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Live Workflow:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{conversation.isLiveWorkflow ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turns */}
|
||||
{conversation.turns && conversation.turns.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Turns ({conversation.turns.length})
|
||||
</div>
|
||||
{conversation.turns.map((turn, index) => (
|
||||
<TurnContainer key={turn.id} turn={turn} index={index} projectId={projectId} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-sm font-mono">No turns in this conversation.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { listConversations } from "@/app/actions/conversation_actions";
|
||||
import { z } from "zod";
|
||||
import { ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
|
||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||
|
||||
type ListedItem = z.infer<typeof ListedConversationItem>;
|
||||
|
||||
export function ConversationsList({ projectId }: { projectId: string }) {
|
||||
const [items, setItems] = useState<ListedItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
|
||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||
const res = await listConversations({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||
return res;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchPage(null);
|
||||
if (ignore) return;
|
||||
setItems(res.items);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const res = await fetchPage(cursor);
|
||||
setItems(prev => [...prev, ...res.items]);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoadingMore(false);
|
||||
}, [cursor, fetchPage]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups: Record<string, ListedItem[]> = {
|
||||
Today: [],
|
||||
'This week': [],
|
||||
'This month': [],
|
||||
Older: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const d = new Date(item.createdAt);
|
||||
if (isToday(d)) groups['Today'].push(item);
|
||||
else if (isThisWeek(d)) groups['This week'].push(item);
|
||||
else if (isThisMonth(d)) groups['This month'].push(item);
|
||||
else groups['Older'].push(item);
|
||||
}
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
CONVERSATIONS
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reserved for future actions */}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="mt-4 text-center">No conversations yet.</p>
|
||||
)}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="flex flex-col gap-8">
|
||||
{Object.entries(sections).map(([label, group]) => (
|
||||
group.length > 0 ? (
|
||||
<div key={label}>
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Conversation</th>
|
||||
<th className="w-[30%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{group.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-6 py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/conversations/${c.id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{c.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(c.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
19
apps/rowboat/app/projects/[projectId]/conversations/page.tsx
Normal file
19
apps/rowboat/app/projects/[projectId]/conversations/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { ConversationsList } from "./components/conversations-list";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Conversations",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <ConversationsList projectId={params.projectId} />;
|
||||
}
|
||||
|
||||
|
||||
17
apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx
Normal file
17
apps/rowboat/app/projects/[projectId]/jobs/[jobId]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { JobView } from "../components/job-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Job",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string, jobId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <JobView projectId={params.projectId} jobId={params.jobId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { fetchJob } from "@/app/actions/job_actions";
|
||||
import { Job } from "@/src/entities/models/job";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { MessageDisplay } from "../../../../lib/components/message-display";
|
||||
|
||||
export function JobView({ projectId, jobId }: { projectId: string; jobId: string; }) {
|
||||
const [job, setJob] = useState<z.infer<typeof Job> | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchJob({ jobId });
|
||||
if (ignore) return;
|
||||
setJob(res);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [jobId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!job) return 'Job';
|
||||
return `Job ${job.id}`;
|
||||
}, [job]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'failed':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'running':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonDisplay = (reason: any) => {
|
||||
if (reason.type === 'composio_trigger') {
|
||||
return {
|
||||
type: 'Composio Trigger',
|
||||
details: {
|
||||
'Trigger Type': reason.triggerTypeSlug,
|
||||
'Trigger ID': reason.triggerId,
|
||||
'Deployment ID': reason.triggerDeploymentId,
|
||||
},
|
||||
payload: reason.payload
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'Unknown',
|
||||
details: {},
|
||||
payload: null
|
||||
};
|
||||
};
|
||||
|
||||
// Extract conversation and turn IDs from job output
|
||||
const conversationId = job?.output?.conversationId;
|
||||
const turnId = job?.output?.turnId;
|
||||
const reasonInfo = job ? getReasonDisplay(job.reason) : null;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={<div className="flex items-center gap-3"><div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div></div>}
|
||||
rightActions={<div className="flex items-center gap-3"></div>}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && job && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Job Metadata */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{job.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
|
||||
<span className={`ml-2 font-mono ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{job.updatedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{new Date(job.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{conversationId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Conversation:</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/conversations/${conversationId}`}
|
||||
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{conversationId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{turnId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Turn:</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/conversations/${conversationId}#turn-${turnId}`}
|
||||
className="ml-2 font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{turnId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{job.output?.error && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-semibold text-red-700 dark:text-red-300">Error:</span>
|
||||
<span className="ml-2 font-mono text-red-600 dark:text-red-400">
|
||||
{job.output.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Reason */}
|
||||
{reasonInfo && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
Job Reason
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
{reasonInfo.type}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{Object.entries(reasonInfo.details).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">{key}:</span>
|
||||
<span className="font-mono text-gray-600 dark:text-gray-400">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reasonInfo.payload && Object.keys(reasonInfo.payload).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Trigger Payload
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[300px]">
|
||||
{JSON.stringify(reasonInfo.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job Input */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
Job Input
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Messages */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Messages ({job.input.messages.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{job.input.messages.map((message, msgIndex) => (
|
||||
<MessageDisplay key={`input-${msgIndex}`} message={message} index={msgIndex} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Workflow
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[400px]">
|
||||
{JSON.stringify(job.input.workflow, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Output */}
|
||||
{job.output && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
Job Output
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono">
|
||||
{JSON.stringify(job.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !job && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-sm font-mono">Job not found.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { listJobs } from "@/app/actions/job_actions";
|
||||
import { z } from "zod";
|
||||
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||
|
||||
type ListedItem = z.infer<typeof ListedJobItem>;
|
||||
|
||||
export function JobsList({ projectId }: { projectId: string }) {
|
||||
const [items, setItems] = useState<ListedItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
|
||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||
const res = await listJobs({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||
return res;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchPage(null);
|
||||
if (ignore) return;
|
||||
setItems(res.items);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const res = await fetchPage(cursor);
|
||||
setItems(prev => [...prev, ...res.items]);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoadingMore(false);
|
||||
}, [cursor, fetchPage]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups: Record<string, ListedItem[]> = {
|
||||
Today: [],
|
||||
'This week': [],
|
||||
'This month': [],
|
||||
Older: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const d = new Date(item.createdAt);
|
||||
if (isToday(d)) groups['Today'].push(item);
|
||||
else if (isThisWeek(d)) groups['This week'].push(item);
|
||||
else if (isThisMonth(d)) groups['This month'].push(item);
|
||||
else groups['Older'].push(item);
|
||||
}
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'failed':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'running':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getReasonDisplay = (reason: any) => {
|
||||
if (reason.type === 'composio_trigger') {
|
||||
return `Composio: ${reason.triggerTypeSlug}`;
|
||||
}
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
JOBS
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reserved for future actions */}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="mt-4 text-center">No jobs yet.</p>
|
||||
)}
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="flex flex-col gap-8">
|
||||
{Object.entries(sections).map(([label, group]) => (
|
||||
group.length > 0 ? (
|
||||
<div key={label}>
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3">{label}</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Job</th>
|
||||
<th className="w-[20%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</th>
|
||||
<th className="w-[25%] px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{group.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-6 py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${job.id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{job.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-mono">
|
||||
{getReasonDisplay(job.reason)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
17
apps/rowboat/app/projects/[projectId]/jobs/page.tsx
Normal file
17
apps/rowboat/app/projects/[projectId]/jobs/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { JobsList } from "./components/jobs-list";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jobs",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <JobsList projectId={params.projectId} />;
|
||||
}
|
||||
|
|
@ -10,34 +10,31 @@ import { getProjectConfig } from '@/app/actions/project_actions';
|
|||
import { z } from 'zod';
|
||||
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
import { ComposioToolsPanel } from './ComposioToolsPanel';
|
||||
import { ToolkitCard } from './ToolkitCard';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { Workflow } from '@/app/lib/types/workflow_types';
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
interface ComposioProps {
|
||||
interface SelectComposioToolkitProps {
|
||||
projectId: string;
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
|
||||
onSelectToolkit: (toolkit: ToolkitType) => void;
|
||||
initialToolkitSlug?: string | null;
|
||||
}
|
||||
|
||||
export function Composio({
|
||||
export function SelectComposioToolkit({
|
||||
projectId,
|
||||
tools,
|
||||
onAddTool,
|
||||
onSelectToolkit,
|
||||
initialToolkitSlug
|
||||
}: ComposioProps) {
|
||||
}: SelectComposioToolkitProps) {
|
||||
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
||||
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
|
||||
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
|
||||
|
||||
const loadProjectConfig = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -84,14 +81,8 @@ export function Composio({
|
|||
}, [projectId]);
|
||||
|
||||
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseToolsPanel = useCallback(() => {
|
||||
setSelectedToolkit(null);
|
||||
setIsToolsPanelOpen(false);
|
||||
}, []);
|
||||
onSelectToolkit(toolkit);
|
||||
}, [onSelectToolkit]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectConfig();
|
||||
|
|
@ -106,11 +97,10 @@ export function Composio({
|
|||
if (initialToolkitSlug && toolkits.length > 0) {
|
||||
const toolkit = toolkits.find(t => t.slug === initialToolkitSlug);
|
||||
if (toolkit) {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
onSelectToolkit(toolkit);
|
||||
}
|
||||
}
|
||||
}, [initialToolkitSlug, toolkits]);
|
||||
}, [initialToolkitSlug, toolkits, onSelectToolkit]);
|
||||
|
||||
const filteredToolkits = toolkits.filter(toolkit => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
|
|
@ -226,15 +216,6 @@ export function Composio({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Panel */}
|
||||
{selectedToolkit && <ComposioToolsPanel
|
||||
toolkit={selectedToolkit}
|
||||
isOpen={isToolsPanelOpen}
|
||||
onClose={handleCloseToolsPanel}
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,10 +3,12 @@
|
|||
import { useState } from 'react';
|
||||
import { Tabs, Tab } from '@/components/ui/tabs';
|
||||
import { CustomMcpServers } from './CustomMcpServer';
|
||||
import { Composio } from './Composio';
|
||||
import { SelectComposioToolkit } from './SelectComposioToolkit';
|
||||
import { ComposioToolsPanel } from './ComposioToolsPanel';
|
||||
import { AddWebhookTool } from './AddWebhookTool';
|
||||
import type { Key } from 'react';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ToolsConfigProps {
|
||||
|
|
@ -17,6 +19,8 @@ interface ToolsConfigProps {
|
|||
initialToolkitSlug?: string | null;
|
||||
}
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
|
||||
export function ToolsConfig({
|
||||
projectId,
|
||||
useComposioTools,
|
||||
|
|
@ -29,11 +33,28 @@ export function ToolsConfig({
|
|||
defaultActiveTab = 'composio';
|
||||
}
|
||||
const [activeTab, setActiveTab] = useState(defaultActiveTab);
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
|
||||
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
|
||||
|
||||
const handleTabChange = (key: Key) => {
|
||||
setActiveTab(key.toString());
|
||||
};
|
||||
|
||||
const handleSelectToolkit = (toolkit: ToolkitType) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseToolsPanel = () => {
|
||||
setSelectedToolkit(null);
|
||||
setIsToolsPanelOpen(false);
|
||||
};
|
||||
|
||||
const handleAddTool = (tool: z.infer<typeof WorkflowTool>) => {
|
||||
onAddTool(tool);
|
||||
handleCloseToolsPanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs
|
||||
|
|
@ -46,10 +67,10 @@ export function ToolsConfig({
|
|||
{useComposioTools && (
|
||||
<Tab key="composio" title="Composio">
|
||||
<div className="mt-4 p-6">
|
||||
<Composio
|
||||
<SelectComposioToolkit
|
||||
projectId={projectId}
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
onSelectToolkit={handleSelectToolkit}
|
||||
initialToolkitSlug={initialToolkitSlug}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -72,6 +93,17 @@ export function ToolsConfig({
|
|||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* Tools Panel */}
|
||||
{selectedToolkit && (
|
||||
<ComposioToolsPanel
|
||||
toolkit={selectedToolkit}
|
||||
isOpen={isToolsPanelOpen}
|
||||
onClose={handleCloseToolsPanel}
|
||||
tools={tools}
|
||||
onAddTool={handleAddTool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button, Card, CardBody, CardHeader, Spinner } from '@heroui/react';
|
||||
import { ChevronLeft, ChevronRight, ZapIcon, ArrowLeft } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
import { listComposioTriggerTypes } from '@/app/actions/composio_actions';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
|
||||
interface ComposioTriggerTypesPanelProps {
|
||||
toolkit: z.infer<typeof ZToolkit>;
|
||||
onBack: () => void;
|
||||
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
|
||||
}
|
||||
|
||||
type TriggerType = z.infer<typeof ComposioTriggerType>;
|
||||
|
||||
export function ComposioTriggerTypesPanel({
|
||||
toolkit,
|
||||
onBack,
|
||||
onSelectTriggerType,
|
||||
}: ComposioTriggerTypesPanelProps) {
|
||||
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
|
||||
try {
|
||||
if (resetList) {
|
||||
setLoading(true);
|
||||
setTriggerTypes([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await listComposioTriggerTypes(toolkit.slug, nextCursor);
|
||||
|
||||
if (resetList) {
|
||||
setTriggerTypes(response.items);
|
||||
} else {
|
||||
setTriggerTypes(prev => [...prev, ...response.items]);
|
||||
}
|
||||
|
||||
setCursor(response.nextCursor);
|
||||
setHasNextPage(!!response.nextCursor);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading trigger types:', err);
|
||||
setError('Failed to load trigger types. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [toolkit.slug]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (cursor && !loadingMore) {
|
||||
loadTriggerTypes(false, cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerTypeSelect = (triggerType: TriggerType) => {
|
||||
onSelectTriggerType(triggerType);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTriggerTypes(true);
|
||||
}, [loadTriggerTypes]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name} Triggers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a trigger type to set up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
<span className="ml-2">Loading trigger types...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name} Triggers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a trigger type to set up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button variant="flat" onPress={() => loadTriggerTypes(true)}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name} Triggers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a trigger type to set up ({triggerTypes.length} available)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggerTypes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
No trigger types available
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
This toolkit doesn't have any trigger types configured.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{triggerTypes.map((triggerType) => (
|
||||
<Card
|
||||
key={triggerType.slug}
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
isPressable
|
||||
onPress={() => handleTriggerTypeSelect(triggerType)}
|
||||
>
|
||||
<CardHeader className="flex gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<ZapIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{triggerType.name}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||
{triggerType.description}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={() => handleTriggerTypeSelect(triggerType)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="flat"
|
||||
onPress={handleLoadMore}
|
||||
isLoading={loadingMore}
|
||||
startContent={!loadingMore ? <ChevronRight className="w-4 h-4" /> : null}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load More'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
|
||||
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
|
||||
interface TriggerConfigFormProps {
|
||||
toolkit: z.infer<typeof ZToolkit>;
|
||||
triggerType: z.infer<typeof ComposioTriggerType>;
|
||||
onBack: () => void;
|
||||
onSubmit: (config: Record<string, unknown>) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
interface JsonSchemaProperty {
|
||||
type: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: any[];
|
||||
}
|
||||
|
||||
interface JsonSchema {
|
||||
type: 'object';
|
||||
properties: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function TriggerConfigForm({
|
||||
toolkit,
|
||||
triggerType,
|
||||
onBack,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: TriggerConfigFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Parse the JSON schema from triggerType.config
|
||||
const schema = triggerType.config as JsonSchema;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Validate required fields
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (schema.required) {
|
||||
schema.required.forEach(fieldName => {
|
||||
if (!formData[fieldName] || formData[fieldName].trim() === '') {
|
||||
const field = schema.properties[fieldName];
|
||||
newErrors[fieldName] = `${field?.title || fieldName} is required`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
// If no errors, submit the form
|
||||
if (Object.keys(newErrors).length === 0) {
|
||||
// Convert form data to appropriate types based on schema
|
||||
const processedData: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const property = schema.properties[key];
|
||||
if (property) {
|
||||
switch (property.type) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
processedData[key] = value ? Number(value) : undefined;
|
||||
break;
|
||||
case 'boolean':
|
||||
processedData[key] = value === 'true';
|
||||
break;
|
||||
default:
|
||||
processedData[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onSubmit(processedData);
|
||||
}
|
||||
}, [formData, schema, onSubmit]);
|
||||
|
||||
const handleFieldChange = useCallback((fieldName: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||
|
||||
// Clear error for this field if it exists
|
||||
if (errors[fieldName]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldName];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
// Check if trigger requires configuration
|
||||
const hasConfigFields = schema && schema.properties && Object.keys(schema.properties).length > 0;
|
||||
|
||||
if (!hasConfigFields) {
|
||||
// No configuration needed - show success state
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{triggerType.name} Configuration
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No additional configuration required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<ZapIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Ready to Create Trigger!
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
This trigger type doesn't require additional configuration. You can create it directly.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
size="lg"
|
||||
onPress={() => onSubmit({})}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="light" isIconOnly onPress={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Configure {triggerType.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{triggerType.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
Trigger Configuration
|
||||
</h4>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure the settings for your {toolkit.name} trigger:
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(schema.properties).map(([fieldName, property]) => {
|
||||
const isRequired = schema.required?.includes(fieldName) || false;
|
||||
const fieldValue = formData[fieldName] || '';
|
||||
const fieldError = errors[fieldName];
|
||||
|
||||
// Handle different input types based on property type
|
||||
if (property.enum) {
|
||||
// Render select for enum fields
|
||||
return (
|
||||
<div key={fieldName}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{property.title || fieldName}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<select
|
||||
value={fieldValue}
|
||||
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
required={isRequired}
|
||||
>
|
||||
<option value="">Select {property.title || fieldName}</option>
|
||||
{property.enum.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{property.description && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{property.description}
|
||||
</p>
|
||||
)}
|
||||
{fieldError && (
|
||||
<p className="mt-1 text-xs text-red-500">{fieldError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={fieldName}
|
||||
label={property.title || fieldName}
|
||||
placeholder={property.description || `Enter ${property.title || fieldName}`}
|
||||
value={fieldValue}
|
||||
onValueChange={(value) => handleFieldChange(fieldName, value)}
|
||||
isRequired={isRequired}
|
||||
type={property.type === 'number' || property.type === 'integer' ? 'number' : 'text'}
|
||||
variant="bordered"
|
||||
description={property.description}
|
||||
isInvalid={!!fieldError}
|
||||
errorMessage={fieldError}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="bordered"
|
||||
onPress={onBack}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creating Trigger...' : 'Create Trigger'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Spinner, Card, CardBody, CardHeader } from '@heroui/react';
|
||||
import { Plus, Trash2, ZapIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
import { listComposioTriggerDeployments, deleteComposioTriggerDeployment, createComposioTriggerDeployment } from '@/app/actions/composio_actions';
|
||||
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
|
||||
import { ComposioTriggerTypesPanel } from './ComposioTriggerTypesPanel';
|
||||
import { TriggerConfigForm } from './TriggerConfigForm';
|
||||
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
|
||||
interface TriggersModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
projectConfig: z.infer<typeof Project>;
|
||||
onProjectConfigUpdated?: () => void;
|
||||
}
|
||||
|
||||
type TriggerDeployment = z.infer<typeof ComposioTriggerDeployment>;
|
||||
|
||||
export function TriggersModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
projectId,
|
||||
projectConfig,
|
||||
onProjectConfigUpdated,
|
||||
}: TriggersModalProps) {
|
||||
const [triggers, setTriggers] = useState<TriggerDeployment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateFlow, setShowCreateFlow] = useState(false);
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<z.infer<typeof ZToolkit> | null>(null);
|
||||
const [selectedTriggerType, setSelectedTriggerType] = useState<z.infer<typeof ComposioTriggerType> | null>(null);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
|
||||
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
|
||||
|
||||
const loadTriggers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await listComposioTriggerDeployments({ projectId });
|
||||
setTriggers(response.items);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading triggers:', err);
|
||||
setError('Failed to load triggers. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleDeleteTrigger = async (deploymentId: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this trigger?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingTrigger(deploymentId);
|
||||
await deleteComposioTriggerDeployment({ projectId, deploymentId });
|
||||
await loadTriggers(); // Reload the list
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting trigger:', err);
|
||||
setError('Failed to delete trigger. Please try again.');
|
||||
} finally {
|
||||
setDeletingTrigger(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setShowCreateFlow(true);
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
setShowCreateFlow(false);
|
||||
setSelectedToolkit(null);
|
||||
setSelectedTriggerType(null);
|
||||
setShowAuthModal(false);
|
||||
setIsSubmittingTrigger(false);
|
||||
loadTriggers(); // Reload in case any triggers were created
|
||||
};
|
||||
|
||||
const handleSelectToolkit = (toolkit: z.infer<typeof ZToolkit>) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
};
|
||||
|
||||
const handleBackToToolkitSelection = () => {
|
||||
setSelectedToolkit(null);
|
||||
setSelectedTriggerType(null);
|
||||
setIsSubmittingTrigger(false);
|
||||
};
|
||||
|
||||
const handleSelectTriggerType = (triggerType: z.infer<typeof ComposioTriggerType>) => {
|
||||
if (!selectedToolkit) return;
|
||||
|
||||
setSelectedTriggerType(triggerType);
|
||||
|
||||
// Check if toolkit requires auth and if connected account exists
|
||||
const needsAuth = !selectedToolkit.no_auth;
|
||||
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
|
||||
|
||||
if (needsAuth && !hasConnection) {
|
||||
// Show auth modal
|
||||
setShowAuthModal(true);
|
||||
} else {
|
||||
// Proceed to trigger configuration
|
||||
// For now this is just the placeholder, but will be actual config later
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthComplete = async () => {
|
||||
setShowAuthModal(false);
|
||||
onProjectConfigUpdated?.();
|
||||
};
|
||||
|
||||
const handleTriggerSubmit = async (triggerConfig: Record<string, unknown>) => {
|
||||
if (!selectedToolkit || !selectedTriggerType) return;
|
||||
|
||||
try {
|
||||
setIsSubmittingTrigger(true);
|
||||
|
||||
// Get the connected account ID for this toolkit
|
||||
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
|
||||
|
||||
if (!connectedAccountId) {
|
||||
throw new Error('No connected account found for this toolkit');
|
||||
}
|
||||
|
||||
// Create the trigger deployment
|
||||
await createComposioTriggerDeployment({
|
||||
projectId,
|
||||
toolkitSlug: selectedToolkit.slug,
|
||||
triggerTypeSlug: selectedTriggerType.slug,
|
||||
connectedAccountId,
|
||||
triggerConfig,
|
||||
});
|
||||
|
||||
// Success! Go back to triggers list and reload
|
||||
handleBackToList();
|
||||
} catch (err: any) {
|
||||
console.error('Error creating trigger:', err);
|
||||
setError('Failed to create trigger. Please try again.');
|
||||
} finally {
|
||||
setIsSubmittingTrigger(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !showCreateFlow) {
|
||||
loadTriggers();
|
||||
}
|
||||
}, [isOpen, showCreateFlow, loadTriggers]);
|
||||
|
||||
const renderTriggerList = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
<span className="ml-2">Loading triggers...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button variant="flat" onPress={loadTriggers}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (triggers.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
No triggers configured
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
Set up your first trigger to listen for events from your connected apps.
|
||||
</p>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="solid"
|
||||
startContent={<Plus className="w-4 h-4" />}
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
Create your first trigger
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Active Triggers ({triggers.length})
|
||||
</h3>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="solid"
|
||||
startContent={<Plus className="w-4 h-4" />}
|
||||
onPress={handleCreateNew}
|
||||
>
|
||||
Create New Trigger
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{triggers.map((trigger) => (
|
||||
<Card key={trigger.id} className="w-full">
|
||||
<CardHeader className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
{trigger.triggerTypeSlug}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Created {new Date(trigger.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
color="danger"
|
||||
size="sm"
|
||||
isLoading={deletingTrigger === trigger.id}
|
||||
onPress={() => handleDeleteTrigger(trigger.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Trigger ID:</strong> {trigger.triggerId}</p>
|
||||
<p><strong>Connected Account:</strong> {trigger.connectedAccountId}</p>
|
||||
{Object.keys(trigger.triggerConfig).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<strong>Configuration:</strong>
|
||||
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded">
|
||||
{JSON.stringify(trigger.triggerConfig, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateFlow = () => {
|
||||
// If trigger type is selected and auth is complete, show config
|
||||
if (selectedToolkit && selectedTriggerType && !showAuthModal) {
|
||||
const needsAuth = !selectedToolkit.no_auth;
|
||||
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
|
||||
|
||||
if (!needsAuth || hasConnection) {
|
||||
return (
|
||||
<TriggerConfigForm
|
||||
toolkit={selectedToolkit}
|
||||
triggerType={selectedTriggerType}
|
||||
onBack={handleBackToToolkitSelection}
|
||||
onSubmit={handleTriggerSubmit}
|
||||
isSubmitting={isSubmittingTrigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no toolkit selected, show toolkit selection
|
||||
if (!selectedToolkit) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Select a Toolkit to Create Trigger
|
||||
</h3>
|
||||
<Button
|
||||
variant="flat"
|
||||
onPress={handleBackToList}
|
||||
>
|
||||
← Back to Triggers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectComposioToolkit
|
||||
projectId={projectId}
|
||||
tools={[]} // Empty array since we're not using this for tools
|
||||
onSelectToolkit={handleSelectToolkit}
|
||||
initialToolkitSlug={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If toolkit selected, show trigger types
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ComposioTriggerTypesPanel
|
||||
toolkit={selectedToolkit}
|
||||
onBack={handleBackToToolkitSelection}
|
||||
onSelectTriggerType={handleSelectTriggerType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="5xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="max-h-[90vh]">
|
||||
<ModalHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ZapIcon className="w-5 h-5" />
|
||||
<span>Manage Triggers</span>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{showCreateFlow ? renderCreateFlow() : renderTriggerList()}
|
||||
</ModalBody>
|
||||
{!showCreateFlow && (
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Auth Modal */}
|
||||
{selectedToolkit && (
|
||||
<ToolkitAuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
toolkitSlug={selectedToolkit.slug}
|
||||
projectId={projectId}
|
||||
onComplete={handleAuthComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import { publishWorkflow } from "@/app/actions/project_actions";
|
|||
import { saveWorkflow } from "@/app/actions/project_actions";
|
||||
import { updateProjectName } from "@/app/actions/project_actions";
|
||||
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
|
||||
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon, ZapIcon } from "lucide-react";
|
||||
import { EntityList } from "./entity_list";
|
||||
import { ProductTour } from "@/components/common/product-tour";
|
||||
import { ModelsResponse } from "@/app/lib/types/billing_types";
|
||||
|
|
@ -37,6 +37,7 @@ import { ConfigApp } from "../config/app";
|
|||
import { InputField } from "@/app/lib/components/input-field";
|
||||
import { VoiceSection } from "../config/components/voice";
|
||||
import { ChatWidgetSection } from "../config/components/project";
|
||||
import { TriggersModal } from "./components/TriggersModal";
|
||||
|
||||
enablePatches();
|
||||
|
||||
|
|
@ -882,6 +883,9 @@ export function WorkflowEditor({
|
|||
// Modal state for chat widget configuration
|
||||
const { isOpen: isChatWidgetModalOpen, onOpen: onChatWidgetModalOpen, onClose: onChatWidgetModalClose } = useDisclosure();
|
||||
|
||||
// Modal state for triggers management
|
||||
const { isOpen: isTriggersModalOpen, onOpen: onTriggersModalOpen, onClose: onTriggersModalClose } = useDisclosure();
|
||||
|
||||
// Project name state
|
||||
const [localProjectName, setLocalProjectName] = useState<string>(projectConfig.name || '');
|
||||
const [projectNameError, setProjectNameError] = useState<string | null>(null);
|
||||
|
|
@ -1359,6 +1363,13 @@ export function WorkflowEditor({
|
|||
>
|
||||
Chat widget
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="manage-triggers"
|
||||
startContent={<ZapIcon size={16} />}
|
||||
onPress={onTriggersModalOpen}
|
||||
>
|
||||
Manage triggers
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
|
@ -1647,6 +1658,15 @@ export function WorkflowEditor({
|
|||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Triggers Management Modal */}
|
||||
<TriggersModal
|
||||
isOpen={isTriggersModalOpen}
|
||||
onClose={onTriggersModalClose}
|
||||
projectId={projectId}
|
||||
projectConfig={projectConfig}
|
||||
onProjectConfigUpdated={onProjectConfigUpdated}
|
||||
/>
|
||||
</div>
|
||||
</EntitySelectionContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
12
apps/rowboat/app/scripts/jobs-worker.ts
Normal file
12
apps/rowboat/app/scripts/jobs-worker.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import '../lib/loadenv';
|
||||
import { container } from "@/di/container";
|
||||
import { IJobsWorker } from "@/src/application/workers/jobs.worker";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const jobsWorker = container.resolve<IJobsWorker>('jobsWorker');
|
||||
await jobsWorker.run();
|
||||
} catch (error) {
|
||||
console.error(`Unable to run jobs worker: ${error}`);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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(),
|
||||
});
|
||||
37
apps/rowboat/package-lock.json
generated
37
apps/rowboat/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Schema for creating a new Composio trigger deployment.
|
||||
* Includes only the required fields for deployment creation.
|
||||
*/
|
||||
export const CreateDeploymentSchema = ComposioTriggerDeployment
|
||||
.pick({
|
||||
projectId: true,
|
||||
triggerId: true,
|
||||
connectedAccountId: true,
|
||||
toolkitSlug: true,
|
||||
logo: true,
|
||||
triggerTypeSlug: true,
|
||||
triggerConfig: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Repository interface for managing Composio trigger deployments.
|
||||
*
|
||||
* This interface defines the contract for operations related to Composio trigger deployments,
|
||||
* including creating, deleting, and querying deployments by various criteria.
|
||||
*
|
||||
* Composio trigger deployments represent the connection between a project's trigger
|
||||
* and a connected account, enabling automated workflows based on external events.
|
||||
*/
|
||||
export interface IComposioTriggerDeploymentsRepository {
|
||||
/**
|
||||
* Creates a new Composio trigger deployment.
|
||||
*
|
||||
* @param data - The deployment data containing projectId, triggerId, connectedAccountId, and triggerTypeSlug
|
||||
* @returns Promise resolving to the created deployment with full details including id, timestamps, and disabled status
|
||||
*/
|
||||
create(data: z.infer<typeof CreateDeploymentSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
|
||||
|
||||
/**
|
||||
* Fetches a trigger deployment by its ID.
|
||||
*
|
||||
* @param id - The unique identifier of the deployment to fetch
|
||||
* @returns Promise resolving to the deployment if found, null if not found
|
||||
*/
|
||||
fetch(id: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null>;
|
||||
|
||||
/**
|
||||
* Deletes a Composio trigger deployment by its ID.
|
||||
*
|
||||
* @param id - The unique identifier of the deployment to delete
|
||||
* @returns Promise resolving to true if the deployment was deleted, false if not found
|
||||
*/
|
||||
delete(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Fetches a trigger deployment by its trigger type slug and connected account ID.
|
||||
*
|
||||
* @param triggerTypeSlug - The slug identifier of the trigger type
|
||||
* @param connectedAccountId - The unique identifier of the connected account
|
||||
* @returns Promise resolving to the deployment if found, null if not found
|
||||
*/
|
||||
fetchBySlugAndConnectedAccountId(triggerTypeSlug: string, connectedAccountId: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null>;
|
||||
|
||||
/**
|
||||
* Retrieves all trigger deployments for a specific project.
|
||||
*
|
||||
* @param projectId - The unique identifier of the project
|
||||
* @param cursor - Optional cursor for pagination
|
||||
* @param limit - Optional limit for the number of items to return
|
||||
* @returns Promise resolving to a paginated list of deployments associated with the project
|
||||
*/
|
||||
listByProjectId(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
|
||||
|
||||
/**
|
||||
* Retrieves all trigger deployments for a specific trigger.
|
||||
*
|
||||
* @param triggerId - The identifier of the trigger
|
||||
* @param cursor - Optional cursor for pagination
|
||||
* @param limit - Optional limit for the number of items to return
|
||||
* @returns Promise resolving to a paginated list of deployments for the specified trigger
|
||||
*/
|
||||
listByTriggerId(triggerId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
|
||||
|
||||
/**
|
||||
* Deletes all trigger deployments associated with a specific connected account.
|
||||
*
|
||||
* This method is typically used when a connected account is disconnected
|
||||
* or when cleaning up deployments for a specific integration.
|
||||
*
|
||||
* @param connectedAccountId - The unique identifier of the connected account
|
||||
* @returns Promise resolving to the number of records deleted
|
||||
*/
|
||||
deleteByConnectedAccountId(connectedAccountId: string): Promise<number>;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import { Conversation } from "@/src/entities/models/conversation";
|
||||
import { Turn } from "@/src/entities/models/turn";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
export const CreateConversationData = Conversation.pick({
|
||||
projectId: true,
|
||||
|
|
@ -14,12 +15,22 @@ export const AddTurnData = Turn.omit({
|
|||
updatedAt: true,
|
||||
});
|
||||
|
||||
export const ListedConversationItem = Conversation.pick({
|
||||
id: true,
|
||||
projectId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export interface IConversationsRepository {
|
||||
// create a new conversation
|
||||
createConversation(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>>;
|
||||
create(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>>;
|
||||
|
||||
// get conversation
|
||||
getConversation(id: string): Promise<z.infer<typeof Conversation> | null>;
|
||||
fetch(id: string): Promise<z.infer<typeof Conversation> | null>;
|
||||
|
||||
// list conversations for project
|
||||
list(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>>;
|
||||
|
||||
// add turn data to conversation
|
||||
// returns the created turn
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { Job } from "@/src/entities/models/job";
|
||||
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
|
||||
import { NotFoundError } from "@/src/entities/errors/common";
|
||||
import { z } from "zod";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
/**
|
||||
* Schema for creating a new job.
|
||||
* Defines the required fields when creating a job in the system.
|
||||
*/
|
||||
const createJobSchema = Job.pick({
|
||||
reason: true,
|
||||
projectId: true,
|
||||
input: true,
|
||||
});
|
||||
|
||||
export const ListedJobItem = Job.pick({
|
||||
id: true,
|
||||
projectId: true,
|
||||
status: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating an existing job.
|
||||
* Defines the fields that can be updated for a job.
|
||||
*/
|
||||
const updateJobSchema = Job.pick({
|
||||
status: true,
|
||||
output: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Repository interface for managing jobs in the system.
|
||||
*
|
||||
* This interface defines the contract for job management operations including
|
||||
* creation, polling, locking, updating, and releasing jobs. Jobs represent
|
||||
* asynchronous tasks that can be processed by workers.
|
||||
*/
|
||||
export interface IJobsRepository {
|
||||
/**
|
||||
* Creates a new job in the system.
|
||||
*
|
||||
* @param data - The job data containing trigger information, project ID, and input
|
||||
* @returns Promise resolving to the created job with all fields populated
|
||||
*/
|
||||
create(data: z.infer<typeof createJobSchema>): Promise<z.infer<typeof Job>>;
|
||||
|
||||
/**
|
||||
* Fetches a job by its unique identifier.
|
||||
*
|
||||
* @param id - The unique identifier of the job to fetch
|
||||
* @returns Promise resolving to the job or null if not found
|
||||
*/
|
||||
fetch(id: string): Promise<z.infer<typeof Job> | null>;
|
||||
|
||||
/**
|
||||
* Polls for the next available job that can be processed by a worker.
|
||||
*
|
||||
* This method should return the next job that is in "pending" status and
|
||||
* is not currently locked by another worker.
|
||||
*
|
||||
* @param workerId - The unique identifier of the worker requesting a job
|
||||
* @returns Promise resolving to the next available job or null if no jobs are available
|
||||
*/
|
||||
poll(workerId: string): Promise<z.infer<typeof Job> | null>;
|
||||
|
||||
/**
|
||||
* Locks a specific job for processing by a worker.
|
||||
*
|
||||
* This method should mark the job as "running" and associate it with the
|
||||
* specified worker ID to prevent other workers from processing it.
|
||||
*
|
||||
* @param id - The unique identifier of the job to lock
|
||||
* @param workerId - The unique identifier of the worker locking the job
|
||||
* @returns Promise resolving to the locked job
|
||||
* @throws {JobAcquisitionError} if the job is already locked or doesn't exist
|
||||
*/
|
||||
lock(id: string, workerId: string): Promise<z.infer<typeof Job>>;
|
||||
|
||||
/**
|
||||
* Updates an existing job with new status and/or output data.
|
||||
*
|
||||
* @param id - The unique identifier of the job to update
|
||||
* @param data - The data to update (status and/or output)
|
||||
* @returns Promise resolving to the updated job
|
||||
* @throws {NotFoundError} if the job doesn't exist
|
||||
*/
|
||||
update(id: string, data: z.infer<typeof updateJobSchema>): Promise<z.infer<typeof Job>>;
|
||||
|
||||
/**
|
||||
* Releases a job lock, making it available for other workers.
|
||||
*
|
||||
* This method should clear the workerId association and potentially
|
||||
* reset the status back to "pending" if the job was not completed.
|
||||
*
|
||||
* @param id - The unique identifier of the job to release
|
||||
* @returns Promise that resolves when the job has been released
|
||||
*/
|
||||
release(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Lists jobs for a specific project with pagination.
|
||||
*
|
||||
* @param projectId - The unique identifier of the project
|
||||
* @param cursor - Optional cursor for pagination
|
||||
* @param limit - Maximum number of jobs to return (default: 50)
|
||||
* @returns Promise resolving to a paginated list of jobs
|
||||
*/
|
||||
list(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import { Project } from "@/src/entities/models/project";
|
||||
|
||||
export interface IProjectsRepository {
|
||||
fetch(id: string): Promise<z.infer<typeof Project> | null>;
|
||||
|
||||
deleteComposioConnectedAccount(projectId: string, toolkitSlug: string): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Represents a subscription to a pub-sub channel.
|
||||
*
|
||||
* This interface provides a way to manage subscriptions to pub-sub channels,
|
||||
* allowing subscribers to unsubscribe from channels when they no longer need
|
||||
* to receive messages.
|
||||
*/
|
||||
export interface Subscription {
|
||||
/**
|
||||
* Unsubscribes from the associated pub-sub channel.
|
||||
*
|
||||
* This method should be called when the subscriber no longer wants to
|
||||
* receive messages from the channel. After calling this method, the
|
||||
* handler function will no longer be invoked for new messages on the channel.
|
||||
*
|
||||
* @returns A promise that resolves when the unsubscribe operation is complete
|
||||
* @throws {Error} If the unsubscribe operation fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const subscription = await pubSubService.subscribe('user-events', (message) => {
|
||||
* console.log('Received message:', message);
|
||||
* });
|
||||
*
|
||||
* // Later, when you want to stop receiving messages
|
||||
* await subscription.unsubscribe();
|
||||
* ```
|
||||
*/
|
||||
unsubscribe(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a publish-subscribe (pub-sub) service.
|
||||
*
|
||||
* This interface defines the contract for a pub-sub service that allows
|
||||
* publishing messages to channels and subscribing to receive messages from
|
||||
* those channels. It provides a decoupled communication pattern where
|
||||
* publishers and subscribers don't need to know about each other directly.
|
||||
*
|
||||
* The service supports:
|
||||
* - Publishing messages to specific channels
|
||||
* - Subscribing to channels to receive messages
|
||||
* - Managing subscriptions with the ability to unsubscribe
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Publishing a message
|
||||
* await pubSubService.publish('user-events', JSON.stringify({
|
||||
* userId: '123',
|
||||
* action: 'login',
|
||||
* timestamp: new Date().toISOString()
|
||||
* }));
|
||||
*
|
||||
* // Subscribing to receive messages
|
||||
* const subscription = await pubSubService.subscribe('user-events', (message) => {
|
||||
* const event = JSON.parse(message);
|
||||
* console.log(`User ${event.userId} performed ${event.action}`);
|
||||
* });
|
||||
*
|
||||
* // Unsubscribing when done
|
||||
* await subscription.unsubscribe();
|
||||
* ```
|
||||
*/
|
||||
export interface IPubSubService {
|
||||
/**
|
||||
* Publishes a message to a specific channel.
|
||||
*
|
||||
* This method sends a message to all subscribers of the specified channel.
|
||||
* The message is delivered asynchronously to all active subscribers.
|
||||
*
|
||||
* @param channel - The channel name to publish the message to
|
||||
* @param message - The message content to publish (typically a JSON string)
|
||||
* @returns A promise that resolves when the message has been published
|
||||
* @throws {Error} If the publish operation fails (e.g., network error, invalid channel)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await pubSubService.publish('notifications', JSON.stringify({
|
||||
* type: 'alert',
|
||||
* message: 'System maintenance scheduled',
|
||||
* priority: 'high'
|
||||
* }));
|
||||
* ```
|
||||
*/
|
||||
publish(channel: string, message: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Subscribes to a channel to receive messages.
|
||||
*
|
||||
* This method creates a subscription to the specified channel. When a message
|
||||
* is published to the channel, the provided handler function will be invoked
|
||||
* with the message content.
|
||||
*
|
||||
* The subscription remains active until the returned subscription object's
|
||||
* `unsubscribe()` method is called.
|
||||
*
|
||||
* @param channel - The channel name to subscribe to
|
||||
* @param handler - A function that will be called when messages are received on the channel.
|
||||
* The function receives the message content as a string parameter.
|
||||
* @returns A promise that resolves to a Subscription object that can be used to unsubscribe
|
||||
* @throws {Error} If the subscribe operation fails (e.g., network error, invalid channel)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const subscription = await pubSubService.subscribe('chat-room-123', (message) => {
|
||||
* const chatMessage = JSON.parse(message);
|
||||
* console.log(`${chatMessage.user}: ${chatMessage.text}`);
|
||||
* });
|
||||
*
|
||||
* // Store the subscription for later cleanup
|
||||
* this.subscriptions.push(subscription);
|
||||
* ```
|
||||
*/
|
||||
subscribe(channel: string, handler: (message: string) => void): Promise<Subscription>;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { CreateDeploymentSchema, IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
|
||||
import { IProjectsRepository } from '../../repositories/projects.repository.interface';
|
||||
import { composio, getToolkit } from '../../../../app/lib/composio/composio';
|
||||
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
data: CreateDeploymentSchema.omit({
|
||||
triggerId: true,
|
||||
logo: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export interface ICreateComposioTriggerDeploymentUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
|
||||
}
|
||||
|
||||
export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTriggerDeploymentUseCase {
|
||||
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
|
||||
private readonly projectsRepository: IProjectsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
composioTriggerDeploymentsRepository,
|
||||
projectsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
|
||||
projectsRepository: IProjectsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
|
||||
this.projectsRepository = projectsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
|
||||
// extract projectid from conversation
|
||||
const { projectId } = request.data;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// get toolkit info
|
||||
const toolkit = await getToolkit(request.data.toolkitSlug);
|
||||
|
||||
// ensure that connected account exists on project
|
||||
const project = await this.projectsRepository.fetch(projectId);
|
||||
if (!project) {
|
||||
throw new NotFoundError('Project not found');
|
||||
}
|
||||
|
||||
// ensure connected account exists
|
||||
const account = project.composioConnectedAccounts?.[request.data.toolkitSlug];
|
||||
if (!account || account.id !== request.data.connectedAccountId) {
|
||||
throw new BadRequestError('Invalid connected account');
|
||||
}
|
||||
|
||||
// ensure that a trigger deployment does not exist for this trigger type and connected account
|
||||
const existingDeployment = await this.composioTriggerDeploymentsRepository.fetchBySlugAndConnectedAccountId(request.data.triggerTypeSlug, request.data.connectedAccountId);
|
||||
if (existingDeployment) {
|
||||
throw new BadRequestError('Trigger deployment already exists');
|
||||
}
|
||||
|
||||
// create trigger on composio
|
||||
const result = await composio.triggers.create(request.data.projectId, request.data.triggerTypeSlug, {
|
||||
connectedAccountId: request.data.connectedAccountId,
|
||||
triggerConfig: request.data.triggerConfig,
|
||||
});
|
||||
|
||||
// create trigger deployment in db
|
||||
return await this.composioTriggerDeploymentsRepository.create({
|
||||
projectId,
|
||||
toolkitSlug: request.data.toolkitSlug,
|
||||
logo: toolkit.meta.logo,
|
||||
triggerId: result.triggerId,
|
||||
connectedAccountId: request.data.connectedAccountId,
|
||||
triggerTypeSlug: request.data.triggerTypeSlug,
|
||||
triggerConfig: request.data.triggerConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
|
||||
import { IProjectsRepository } from '../../repositories/projects.repository.interface';
|
||||
import { composio } from '../../../../app/lib/composio/composio';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
export interface IDeleteComposioTriggerDeploymentUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class DeleteComposioTriggerDeploymentUseCase implements IDeleteComposioTriggerDeploymentUseCase {
|
||||
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
|
||||
private readonly projectsRepository: IProjectsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
composioTriggerDeploymentsRepository,
|
||||
projectsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
|
||||
projectsRepository: IProjectsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
|
||||
this.projectsRepository = projectsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
|
||||
// extract projectid from conversation
|
||||
const { projectId } = request;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// ensure deployment belongs to this project
|
||||
const deployment = await this.composioTriggerDeploymentsRepository.fetch(request.deploymentId);
|
||||
if (!deployment || deployment.projectId !== projectId) {
|
||||
throw new NotFoundError('Deployment not found');
|
||||
}
|
||||
|
||||
// delete trigger from composio
|
||||
await composio.triggers.delete(deployment.triggerId);
|
||||
|
||||
// delete deployment
|
||||
return await this.composioTriggerDeploymentsRepository.delete(request.deploymentId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
|
||||
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
|
||||
import { PaginatedList } from '@/src/entities/common/paginated-list';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export interface IListComposioTriggerDeploymentsUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
|
||||
}
|
||||
|
||||
export class ListComposioTriggerDeploymentsUseCase implements IListComposioTriggerDeploymentsUseCase {
|
||||
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
composioTriggerDeploymentsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
|
||||
// extract projectid from conversation
|
||||
const { projectId, limit } = request;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// fetch deployments for project
|
||||
return await this.composioTriggerDeploymentsRepository.listByProjectId(projectId, request.cursor, limit);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from "zod";
|
||||
import { listTriggersTypes } from '../../../../app/lib/composio/composio';
|
||||
import { PaginatedList } from '@/src/entities/common/paginated-list';
|
||||
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
|
||||
|
||||
const inputSchema = z.object({
|
||||
toolkitSlug: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface IListComposioTriggerTypesUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>>;
|
||||
}
|
||||
|
||||
export class ListComposioTriggerTypesUseCase implements IListComposioTriggerTypesUseCase {
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>> {
|
||||
// call composio api to fetch trigger types
|
||||
const result = await listTriggersTypes(request.toolkitSlug, request.cursor);
|
||||
|
||||
// return paginated list of trigger types
|
||||
return {
|
||||
items: result.items,
|
||||
nextCursor: result.next_cursor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { z } from "zod";
|
||||
import { IProjectsRepository } from "../../repositories/projects.repository.interface";
|
||||
import { IProjectActionAuthorizationPolicy } from "../../policies/project-action-authorization.policy";
|
||||
import { IUsageQuotaPolicy } from "../../policies/usage-quota.policy.interface";
|
||||
import { IComposioTriggerDeploymentsRepository } from "../../repositories/composio-trigger-deployments.repository.interface";
|
||||
import { BadRequestError, NotFoundError } from "@/src/entities/errors/common";
|
||||
import { deleteConnectedAccount } from "../../../../app/lib/composio/composio";
|
||||
import { getAuthConfig } from "../../../../app/lib/composio/composio";
|
||||
import { deleteAuthConfig } from "../../../../app/lib/composio/composio";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
toolkitSlug: z.string(),
|
||||
connectedAccountId: z.string(),
|
||||
});
|
||||
|
||||
export interface IDeleteComposioConnectedAccountUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<void>;
|
||||
}
|
||||
|
||||
export class DeleteComposioConnectedAccountUseCase implements IDeleteComposioConnectedAccountUseCase {
|
||||
private readonly projectsRepository: IProjectsRepository;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
|
||||
|
||||
constructor({
|
||||
projectsRepository,
|
||||
projectActionAuthorizationPolicy,
|
||||
usageQuotaPolicy,
|
||||
composioTriggerDeploymentsRepository,
|
||||
}: {
|
||||
projectsRepository: IProjectsRepository,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
|
||||
}) {
|
||||
this.projectsRepository = projectsRepository;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<void> {
|
||||
// extract projectid from conversation
|
||||
const { projectId } = request;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// fetch project
|
||||
const project = await this.projectsRepository.fetch(projectId);
|
||||
if (!project) {
|
||||
throw new NotFoundError('Project not found');
|
||||
}
|
||||
|
||||
// ensure connected account exists
|
||||
const account = project.composioConnectedAccounts?.[request.toolkitSlug];
|
||||
if (!account || account.id !== request.connectedAccountId) {
|
||||
throw new BadRequestError('Invalid connected account');
|
||||
}
|
||||
|
||||
// delete the connected account from composio
|
||||
// this will also delete any trigger instances associated with the connected account
|
||||
const result = await deleteConnectedAccount(request.connectedAccountId);
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to delete connected account ${request.connectedAccountId}`);
|
||||
}
|
||||
|
||||
// delete trigger deployments data from db
|
||||
await this.composioTriggerDeploymentsRepository.deleteByConnectedAccountId(request.connectedAccountId);
|
||||
|
||||
// get auth config data
|
||||
const authConfig = await getAuthConfig(account.authConfigId);
|
||||
|
||||
// delete the auth config if it is NOT managed by composio
|
||||
if (!authConfig.is_composio_managed) {
|
||||
const result = await deleteAuthConfig(account.authConfigId);
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to delete auth config ${account.authConfigId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// delete connected account from project
|
||||
await this.projectsRepository.deleteComposioConnectedAccount(projectId, request.toolkitSlug);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { IJobsRepository } from "@/src/application/repositories/jobs.repository.interface";
|
||||
import { IComposioTriggerDeploymentsRepository } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
|
||||
import { Webhook } from "standardwebhooks";
|
||||
import { z } from "zod";
|
||||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import { UserMessage } from "@/app/lib/types/types";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import { IProjectsRepository } from "@/src/application/repositories/projects.repository.interface";
|
||||
import { IPubSubService } from "@/src/application/services/pub-sub.service.interface";
|
||||
|
||||
const WEBHOOK_SECRET = process.env.COMPOSIO_TRIGGERS_WEBHOOK_SECRET || "test";
|
||||
|
||||
/*
|
||||
{
|
||||
"type": "slack_receive_message",
|
||||
"timestamp": "2025-08-06T01:49:46.008Z",
|
||||
"data": {
|
||||
"bot_id": null,
|
||||
"channel": "C08PTQKM2DS",
|
||||
"channel_type": "channel",
|
||||
"team_id": null,
|
||||
"text": "test",
|
||||
"ts": "1754444983.699449",
|
||||
"user": "U077XPW36V9",
|
||||
"connection_id": "551d86b3-44e3-4c62-b996-44648ccf77b3",
|
||||
"connection_nano_id": "ca_2n0cZnluJ1qc",
|
||||
"trigger_nano_id": "ti_dU7LJMfP5KSr",
|
||||
"trigger_id": "ec96b753-c745-4f37-b5d8-82a35ce0fa0b",
|
||||
"user_id": "987dbd2e-c455-4c8f-8d55-a997a2d7680a"
|
||||
}
|
||||
}
|
||||
*/
|
||||
const requestSchema = z.object({
|
||||
headers: z.record(z.string(), z.string()),
|
||||
payload: z.string(),
|
||||
});
|
||||
|
||||
const payloadSchema = z.object({
|
||||
type: z.string(),
|
||||
timestamp: z.string().datetime(),
|
||||
data: z.object({
|
||||
trigger_nano_id: z.string(),
|
||||
}).passthrough(),
|
||||
});
|
||||
|
||||
export interface IHandleCompsioWebhookRequestUseCase {
|
||||
execute(request: z.infer<typeof requestSchema>): Promise<void>;
|
||||
}
|
||||
|
||||
export class HandleCompsioWebhookRequestUseCase implements IHandleCompsioWebhookRequestUseCase {
|
||||
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
|
||||
private readonly jobsRepository: IJobsRepository;
|
||||
private readonly projectsRepository: IProjectsRepository;
|
||||
private readonly pubSubService: IPubSubService;
|
||||
private webhook;
|
||||
|
||||
constructor({
|
||||
composioTriggerDeploymentsRepository,
|
||||
jobsRepository,
|
||||
projectsRepository,
|
||||
pubSubService,
|
||||
}: {
|
||||
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
|
||||
jobsRepository: IJobsRepository;
|
||||
projectsRepository: IProjectsRepository;
|
||||
pubSubService: IPubSubService;
|
||||
}) {
|
||||
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
|
||||
this.jobsRepository = jobsRepository;
|
||||
this.projectsRepository = projectsRepository;
|
||||
this.pubSubService = pubSubService;
|
||||
this.webhook = new Webhook(WEBHOOK_SECRET);
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof requestSchema>): Promise<void> {
|
||||
const { headers, payload } = request;
|
||||
|
||||
// verify payload
|
||||
// try {
|
||||
// this.webhook.verify(payload, headers);
|
||||
// } catch (error) {
|
||||
// throw new BadRequestError("Payload verification failed");
|
||||
// }
|
||||
|
||||
// parse event
|
||||
let event: z.infer<typeof payloadSchema>;
|
||||
try {
|
||||
event = payloadSchema.parse(JSON.parse(payload));
|
||||
} catch (error) {
|
||||
throw new BadRequestError("Invalid webhook payload");
|
||||
}
|
||||
|
||||
const logger = new PrefixLogger(`composio-trigger-webhook-[${event.type}]-[${event.data.trigger_nano_id}]`);
|
||||
|
||||
// create a job for each deployment across all pages
|
||||
const msg: z.infer<typeof UserMessage> = {
|
||||
role: "user",
|
||||
content: `This chat is being invoked through a trigger. Here is the trigger data:\n\n${JSON.stringify(event, null, 2)}`,
|
||||
};
|
||||
|
||||
// fetch registered trigger deployments for this event type
|
||||
let cursor: string | null = null;
|
||||
let jobs = 0;
|
||||
do {
|
||||
const triggerDeployments = await this.composioTriggerDeploymentsRepository.listByTriggerId(event.data.trigger_nano_id, cursor || undefined);
|
||||
|
||||
// create a job for each deployment in the current page
|
||||
for (const deployment of triggerDeployments.items) {
|
||||
// fetch project
|
||||
const project = await this.projectsRepository.fetch(deployment.projectId);
|
||||
if (!project) {
|
||||
logger.log(`Project ${deployment.projectId} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ensure workflow
|
||||
if (!project.liveWorkflow) {
|
||||
logger.log(`Project ${deployment.projectId} has no live workflow`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// create job
|
||||
const job = await this.jobsRepository.create({
|
||||
reason: {
|
||||
type: "composio_trigger",
|
||||
triggerId: event.data.trigger_nano_id,
|
||||
triggerDeploymentId: deployment.id,
|
||||
triggerTypeSlug: deployment.triggerTypeSlug,
|
||||
payload: event,
|
||||
},
|
||||
projectId: deployment.projectId,
|
||||
input: {
|
||||
workflow: project.liveWorkflow,
|
||||
messages: [msg],
|
||||
},
|
||||
});
|
||||
|
||||
// notify workers
|
||||
await this.pubSubService.publish('new_jobs', job.id);
|
||||
|
||||
jobs++;
|
||||
logger.log(`Created job ${job.id} for trigger deployment ${deployment.id}`);
|
||||
}
|
||||
|
||||
// check if there are more pages
|
||||
cursor = triggerDeployments.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
logger.log(`Created ${jobs} jobs for trigger ${event.data.trigger_nano_id}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
|
|||
|
||||
async execute(data: z.infer<typeof inputSchema>): Promise<{ key: string }> {
|
||||
// fetch conversation
|
||||
const conversation = await this.conversationsRepository.getConversation(data.conversationId);
|
||||
const conversation = await this.conversationsRepository.fetch(data.conversationId);
|
||||
if (!conversation) {
|
||||
throw new NotFoundError('Conversation not found');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IConversationsRepository } from '../../repositories/conversations.repository.interface';
|
||||
import { Conversation } from '@/src/entities/models/conversation';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
conversationId: z.string(),
|
||||
});
|
||||
|
||||
export interface IFetchConversationUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>>;
|
||||
}
|
||||
|
||||
export class FetchConversationUseCase implements IFetchConversationUseCase {
|
||||
private readonly conversationsRepository: IConversationsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
conversationsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
conversationsRepository: IConversationsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.conversationsRepository = conversationsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>> {
|
||||
// fetch conversation first to get projectId
|
||||
const conversation = await this.conversationsRepository.fetch(request.conversationId);
|
||||
if (!conversation) {
|
||||
throw new NotFoundError(`Conversation ${request.conversationId} not found`);
|
||||
}
|
||||
|
||||
// extract projectid from conversation
|
||||
const { projectId } = conversation;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// return the conversation
|
||||
return conversation;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IConversationsRepository, ListedConversationItem } from '../../repositories/conversations.repository.interface';
|
||||
import { Conversation } from '@/src/entities/models/conversation';
|
||||
import { PaginatedList } from '@/src/entities/common/paginated-list';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export interface IListConversationsUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>>;
|
||||
}
|
||||
|
||||
export class ListConversationsUseCase implements IListConversationsUseCase {
|
||||
private readonly conversationsRepository: IConversationsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
conversationsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
conversationsRepository: IConversationsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.conversationsRepository = conversationsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>> {
|
||||
// extract projectid from request
|
||||
const { projectId, limit } = request;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// fetch conversations for project
|
||||
return await this.conversationsRepository.list(projectId, request.cursor, limit);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,11 @@ import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
|||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
caller: z.enum(["user", "api", "job_worker"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
conversationId: z.string(),
|
||||
trigger: Turn.shape.trigger,
|
||||
reason: Turn.shape.reason,
|
||||
input: Turn.shape.input,
|
||||
});
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
|
|||
|
||||
async *execute(data: z.infer<typeof inputSchema>): AsyncGenerator<z.infer<typeof TurnEvent>, void, unknown> {
|
||||
// fetch conversation
|
||||
const conversation = await this.conversationsRepository.getConversation(data.conversationId);
|
||||
const conversation = await this.conversationsRepository.fetch(data.conversationId);
|
||||
if (!conversation) {
|
||||
throw new NotFoundError('Conversation not found');
|
||||
}
|
||||
|
|
@ -52,12 +52,14 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
|
|||
const { id: conversationId, projectId } = conversation;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: data.caller,
|
||||
userId: data.userId,
|
||||
apiKey: data.apiKey,
|
||||
projectId,
|
||||
});
|
||||
if (data.caller !== "job_worker") {
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: data.caller,
|
||||
userId: data.userId,
|
||||
apiKey: data.apiKey,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
|
@ -129,7 +131,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
|
|||
} else {
|
||||
// save turn data
|
||||
const turn = await this.conversationsRepository.addTurn(data.conversationId, {
|
||||
trigger: data.trigger,
|
||||
reason: data.reason,
|
||||
input: data.input,
|
||||
output: outputMessages,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IJobsRepository } from '../../repositories/jobs.repository.interface';
|
||||
import { Job } from '@/src/entities/models/job';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
jobId: z.string(),
|
||||
});
|
||||
|
||||
export interface IFetchJobUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>>;
|
||||
}
|
||||
|
||||
export class FetchJobUseCase implements IFetchJobUseCase {
|
||||
private readonly jobsRepository: IJobsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
jobsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
jobsRepository: IJobsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.jobsRepository = jobsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>> {
|
||||
// fetch job first to get projectId
|
||||
const job = await this.jobsRepository.fetch(request.jobId);
|
||||
if (!job) {
|
||||
throw new NotFoundError(`Job ${request.jobId} not found`);
|
||||
}
|
||||
|
||||
// extract projectid from job
|
||||
const { projectId } = job;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// return the job
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IJobsRepository, ListedJobItem } from '../../repositories/jobs.repository.interface';
|
||||
import { Job } from '@/src/entities/models/job';
|
||||
import { PaginatedList } from '@/src/entities/common/paginated-list';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export interface IListJobsUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
|
||||
}
|
||||
|
||||
export class ListJobsUseCase implements IListJobsUseCase {
|
||||
private readonly jobsRepository: IJobsRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
jobsRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
jobsRepository: IJobsRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.jobsRepository = jobsRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
|
||||
// extract projectid from request
|
||||
const { projectId, limit } = request;
|
||||
|
||||
// authz check
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// assert and consume quota
|
||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||
|
||||
// fetch jobs for project
|
||||
return await this.jobsRepository.list(projectId, request.cursor, limit);
|
||||
}
|
||||
}
|
||||
250
apps/rowboat/src/application/workers/jobs.worker.ts
Normal file
250
apps/rowboat/src/application/workers/jobs.worker.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { IJobsRepository } from "@/src/application/repositories/jobs.repository.interface";
|
||||
import { ICreateConversationUseCase } from "../use-cases/conversations/create-conversation.use-case";
|
||||
import { IRunConversationTurnUseCase } from "../use-cases/conversations/run-conversation-turn.use-case";
|
||||
import { Job } from "@/src/entities/models/job";
|
||||
import { Turn } from "@/src/entities/models/turn";
|
||||
import { IPubSubService, Subscription } from "../services/pub-sub.service.interface";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
|
||||
export interface IJobsWorker {
|
||||
run(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export class JobsWorker implements IJobsWorker {
|
||||
private readonly jobsRepository: IJobsRepository;
|
||||
private readonly createConversationUseCase: ICreateConversationUseCase;
|
||||
private readonly runConversationTurnUseCase: IRunConversationTurnUseCase;
|
||||
private readonly pubSubService: IPubSubService;
|
||||
private workerId: string;
|
||||
private subscription: Subscription | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private pollInterval: number = 5000; // 5 seconds
|
||||
private logger: PrefixLogger;
|
||||
private pollTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor({
|
||||
jobsRepository,
|
||||
createConversationUseCase,
|
||||
runConversationTurnUseCase,
|
||||
pubSubService,
|
||||
}: {
|
||||
jobsRepository: IJobsRepository;
|
||||
createConversationUseCase: ICreateConversationUseCase;
|
||||
runConversationTurnUseCase: IRunConversationTurnUseCase;
|
||||
pubSubService: IPubSubService;
|
||||
}) {
|
||||
this.jobsRepository = jobsRepository;
|
||||
this.createConversationUseCase = createConversationUseCase;
|
||||
this.runConversationTurnUseCase = runConversationTurnUseCase;
|
||||
this.pubSubService = pubSubService;
|
||||
this.workerId = nanoid();
|
||||
this.logger = new PrefixLogger(`jobs-worker-[${this.workerId}]`);
|
||||
}
|
||||
|
||||
async processJob(job: z.infer<typeof Job>): Promise<void> {
|
||||
const logger = this.logger.child(`job-${job.id}`);
|
||||
logger.log('Processing job');
|
||||
|
||||
try {
|
||||
// extract project id from job
|
||||
const { projectId } = job;
|
||||
|
||||
// create conversation
|
||||
logger.log('Creating conversation');
|
||||
const conversation = await this.createConversationUseCase.execute({
|
||||
caller: "job_worker",
|
||||
projectId,
|
||||
workflow: job.input.workflow,
|
||||
isLiveWorkflow: true,
|
||||
});
|
||||
logger.log(`Created conversation ${conversation.id}`);
|
||||
|
||||
// run turn
|
||||
logger.log('Running turn');
|
||||
const stream = this.runConversationTurnUseCase.execute({
|
||||
caller: "job_worker",
|
||||
conversationId: conversation.id,
|
||||
reason: {
|
||||
type: "job",
|
||||
jobId: job.id,
|
||||
},
|
||||
input: {
|
||||
messages: job.input.messages,
|
||||
},
|
||||
});
|
||||
let turn: z.infer<typeof Turn> | null = null;
|
||||
for await (const event of stream) {
|
||||
logger.log(`Received event: ${event.type}`);
|
||||
if (event.type === "done") {
|
||||
turn = event.turn;
|
||||
}
|
||||
}
|
||||
if (!turn) {
|
||||
throw new Error("Turn not created");
|
||||
}
|
||||
logger.log(`Completed turn ${turn.id}`);
|
||||
|
||||
// update job
|
||||
await this.jobsRepository.update(job.id, {
|
||||
status: "completed",
|
||||
output: {
|
||||
conversationId: conversation.id,
|
||||
turnId: turn.id,
|
||||
},
|
||||
});
|
||||
logger.log(`Completed successfully`);
|
||||
} catch (error) {
|
||||
logger.log(`Failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
|
||||
// update job
|
||||
await this.jobsRepository.update(job.id, {
|
||||
status: "failed",
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
// release job
|
||||
await this.jobsRepository.release(job.id);
|
||||
logger.log(`Released`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNewJobMessage(message: string): Promise<void> {
|
||||
const logger = this.logger.child(`handle-new-job-message-${message}`);
|
||||
try {
|
||||
const jobId = message.trim();
|
||||
if (!jobId) {
|
||||
logger.log("Received empty job ID");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Received job ${jobId} via subscription`);
|
||||
|
||||
// Try to lock the specific job
|
||||
let job: z.infer<typeof Job> | null = null;
|
||||
try {
|
||||
job = await this.jobsRepository.lock(jobId, this.workerId);
|
||||
logger.log(`Successfully locked job`);
|
||||
} catch (error) {
|
||||
// Job might already be locked by another worker or doesn't exist
|
||||
logger.log(`Failed to lock job: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
if (!job) {
|
||||
logger.log("Job not found");
|
||||
return;
|
||||
}
|
||||
logger.log(`Processing job ${job.id}`);
|
||||
await this.processJob(job);
|
||||
logger.log(`Processed job ${job.id}`);
|
||||
} catch (error) {
|
||||
logger.log(`Error handling new job message: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollForJobs(): Promise<void> {
|
||||
const logger = this.logger.child(`poll-for-jobs`);
|
||||
try {
|
||||
// fetch next job
|
||||
const job = await this.jobsRepository.poll(this.workerId);
|
||||
|
||||
// if no job found, return early
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Found job ${job.id} via polling`);
|
||||
|
||||
// process job
|
||||
await this.processJob(job);
|
||||
} catch (error) {
|
||||
logger.log(`Error polling for jobs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async startPolling(): Promise<void> {
|
||||
const logger = this.logger.child(`start-polling`);
|
||||
logger.log("Starting polling mechanism");
|
||||
|
||||
const scheduleNextPoll = () => {
|
||||
this.pollTimeoutId = setTimeout(async () => {
|
||||
await this.pollForJobs();
|
||||
// Schedule the next poll after this one completes
|
||||
scheduleNextPoll();
|
||||
}, this.pollInterval);
|
||||
};
|
||||
|
||||
// Start the first poll
|
||||
scheduleNextPoll();
|
||||
}
|
||||
|
||||
private async startSubscription(): Promise<void> {
|
||||
const logger = this.logger.child(`start-subscription`);
|
||||
try {
|
||||
logger.log("Subscribing to new_jobs topic");
|
||||
this.subscription = await this.pubSubService.subscribe(
|
||||
'new_jobs',
|
||||
(message: string) => {
|
||||
// Handle the message asynchronously to avoid blocking the subscription
|
||||
this.handleNewJobMessage(message).catch(error => {
|
||||
logger.log(`Error handling subscription message: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
});
|
||||
}
|
||||
);
|
||||
logger.log("Successfully subscribed to new_jobs topic");
|
||||
} catch (error) {
|
||||
logger.log(`Failed to subscribe to new_jobs topic: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
this.logger.log("Worker is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.logger.log(`Starting worker ${this.workerId}`);
|
||||
|
||||
try {
|
||||
// Start subscription to new_jobs topic
|
||||
await this.startSubscription();
|
||||
|
||||
// Start polling as a fallback mechanism (run concurrently)
|
||||
// We run both operations concurrently - the subscription will handle immediate jobs
|
||||
// while polling will catch any jobs that slipped through
|
||||
await this.startPolling();
|
||||
} catch (error) {
|
||||
this.logger.log(`Error in worker run loop: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
this.logger.log("Worker run loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.logger.log(`Stopping worker ${this.workerId}`);
|
||||
this.isRunning = false;
|
||||
|
||||
// Clear any pending polls
|
||||
if (this.pollTimeoutId) {
|
||||
clearTimeout(this.pollTimeoutId);
|
||||
this.pollTimeoutId = null;
|
||||
this.logger.log("Cleared pending poll timeout");
|
||||
}
|
||||
|
||||
// Unsubscribe from the topic
|
||||
if (this.subscription) {
|
||||
try {
|
||||
await this.subscription.unsubscribe();
|
||||
this.logger.log("Successfully unsubscribed from new_jobs topic");
|
||||
} catch (error) {
|
||||
this.logger.log(`Error unsubscribing from new_jobs topic: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
this.subscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/rowboat/src/entities/common/paginated-list.ts
Normal file
6
apps/rowboat/src/entities/common/paginated-list.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const PaginatedList = <T extends z.ZodTypeAny>(schema: T) => z.object({
|
||||
items: z.array(schema),
|
||||
nextCursor: z.string().nullable(),
|
||||
});
|
||||
5
apps/rowboat/src/entities/errors/job-errors.ts
Normal file
5
apps/rowboat/src/entities/errors/job-errors.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class JobAcquisitionError extends Error {
|
||||
constructor(message?: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
13
apps/rowboat/src/entities/models/composio-trigger-type.ts
Normal file
13
apps/rowboat/src/entities/models/composio-trigger-type.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ComposioTriggerType = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
config: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.any()),
|
||||
required: z.array(z.string()).optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
38
apps/rowboat/src/entities/models/job.ts
Normal file
38
apps/rowboat/src/entities/models/job.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Message } from "@/app/lib/types/types";
|
||||
import { Workflow } from "@/app/lib/types/workflow_types";
|
||||
import { z } from "zod";
|
||||
|
||||
const composioTriggerReason = z.object({
|
||||
type: z.literal("composio_trigger"),
|
||||
triggerId: z.string(),
|
||||
triggerDeploymentId: z.string(),
|
||||
triggerTypeSlug: z.string(),
|
||||
payload: z.object({}).passthrough(),
|
||||
});
|
||||
|
||||
const reason = composioTriggerReason;
|
||||
|
||||
export const Job = z.object({
|
||||
id: z.string(),
|
||||
reason,
|
||||
projectId: z.string(),
|
||||
input: z.object({
|
||||
workflow: Workflow,
|
||||
messages: z.array(Message),
|
||||
}),
|
||||
output: z.object({
|
||||
conversationId: z.string().optional(),
|
||||
turnId: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}).optional(),
|
||||
workerId: z.string().nullable(),
|
||||
lastWorkerId: z.string().nullable(),
|
||||
status: z.enum([
|
||||
"pending",
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
]),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime().optional(),
|
||||
});
|
||||
10
apps/rowboat/src/entities/models/project.ts
Normal file
10
apps/rowboat/src/entities/models/project.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from "zod";
|
||||
import { Project as ExistingProjectSchema } from "@/app/lib/types/project_types";
|
||||
|
||||
export const Project = ExistingProjectSchema
|
||||
.omit({
|
||||
_id: true,
|
||||
})
|
||||
.extend({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { z } from "zod";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { CreateDeploymentSchema, IComposioTriggerDeploymentsRepository } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
|
||||
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
/**
|
||||
* MongoDB document schema for ComposioTriggerDeployment.
|
||||
* Excludes the 'id' field as it's represented by MongoDB's '_id'.
|
||||
*/
|
||||
const DocSchema = ComposioTriggerDeployment.omit({
|
||||
id: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* MongoDB implementation of the ComposioTriggerDeploymentsRepository.
|
||||
*
|
||||
* This repository manages Composio trigger deployments in MongoDB,
|
||||
* providing CRUD operations and paginated queries for deployments.
|
||||
*/
|
||||
export class MongodbComposioTriggerDeploymentsRepository implements IComposioTriggerDeploymentsRepository {
|
||||
private readonly collection = db.collection<z.infer<typeof DocSchema>>("composio_trigger_deployments");
|
||||
|
||||
constructor() {
|
||||
// Create indexes for efficient querying
|
||||
this.createIndexes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the necessary indexes for efficient querying.
|
||||
*/
|
||||
private async createIndexes(): Promise<void> {
|
||||
await this.collection.createIndexes([
|
||||
{ key: { projectId: 1 }, name: "projectId_idx" },
|
||||
{ key: { triggerTypeSlug: 1 }, name: "triggerTypeSlug_idx" },
|
||||
{ key: { connectedAccountId: 1 }, name: "connectedAccountId_idx" },
|
||||
{ key: { triggerId: 1 }, name: "triggerId_idx" },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Composio trigger deployment.
|
||||
*/
|
||||
async create(data: z.infer<typeof CreateDeploymentSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
|
||||
const now = new Date().toISOString();
|
||||
const _id = new ObjectId();
|
||||
|
||||
const doc = {
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.collection.insertOne({
|
||||
...doc,
|
||||
_id,
|
||||
});
|
||||
|
||||
return {
|
||||
...doc,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a trigger deployment by its ID.
|
||||
*/
|
||||
async fetch(id: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null> {
|
||||
const result = await this.collection.findOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Composio trigger deployment by its ID.
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.collection.deleteOne({
|
||||
_id: new ObjectId(id),
|
||||
});
|
||||
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a trigger deployment by its trigger type slug and connected account ID.
|
||||
*/
|
||||
async fetchBySlugAndConnectedAccountId(triggerTypeSlug: string, connectedAccountId: string): Promise<z.infer<typeof ComposioTriggerDeployment> | null> {
|
||||
const result = await this.collection.findOne({
|
||||
triggerTypeSlug,
|
||||
connectedAccountId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all trigger deployments for a specific project with pagination.
|
||||
*/
|
||||
async listByProjectId(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
|
||||
const query: any = { projectId };
|
||||
|
||||
if (cursor) {
|
||||
query._id = { $gt: new ObjectId(cursor) };
|
||||
}
|
||||
|
||||
const results = await this.collection
|
||||
.find(query)
|
||||
.sort({ _id: 1 })
|
||||
.limit(limit + 1) // Fetch one extra to determine if there's a next page
|
||||
.toArray();
|
||||
|
||||
const hasNextPage = results.length > limit;
|
||||
const items = results.slice(0, limit).map(doc => {
|
||||
const { _id, ...rest } = doc;
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all trigger deployments for a specific trigger with pagination.
|
||||
*/
|
||||
async listByTriggerId(triggerId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
|
||||
const query: any = { triggerId };
|
||||
|
||||
if (cursor) {
|
||||
query._id = { $gt: new ObjectId(cursor) };
|
||||
}
|
||||
|
||||
const results = await this.collection
|
||||
.find(query)
|
||||
.sort({ _id: 1 })
|
||||
.limit(limit + 1) // Fetch one extra to determine if there's a next page
|
||||
.toArray();
|
||||
|
||||
const hasNextPage = results.length > limit;
|
||||
const items = results.slice(0, limit).map(doc => {
|
||||
const { _id, ...rest } = doc;
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all trigger deployments associated with a specific connected account.
|
||||
*/
|
||||
async deleteByConnectedAccountId(connectedAccountId: string): Promise<number> {
|
||||
const result = await this.collection.deleteMany({
|
||||
connectedAccountId,
|
||||
});
|
||||
|
||||
return result.deletedCount;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { z } from "zod";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { AddTurnData, CreateConversationData, IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface";
|
||||
import { AddTurnData, CreateConversationData, IConversationsRepository, ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
|
||||
import { Conversation } from "@/src/entities/models/conversation";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Turn } from "@/src/entities/models/turn";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
const DocSchema = Conversation
|
||||
.omit({
|
||||
|
|
@ -14,7 +15,7 @@ const DocSchema = Conversation
|
|||
export class MongoDBConversationsRepository implements IConversationsRepository {
|
||||
private readonly collection = db.collection<z.infer<typeof DocSchema>>("conversations");
|
||||
|
||||
async createConversation(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>> {
|
||||
async create(data: z.infer<typeof CreateConversationData>): Promise<z.infer<typeof Conversation>> {
|
||||
const now = new Date();
|
||||
const _id = new ObjectId();
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ export class MongoDBConversationsRepository implements IConversationsRepository
|
|||
};
|
||||
}
|
||||
|
||||
async getConversation(id: string): Promise<z.infer<typeof Conversation> | null> {
|
||||
async fetch(id: string): Promise<z.infer<typeof Conversation> | null> {
|
||||
const result = await this.collection.findOne({
|
||||
_id: new ObjectId(id),
|
||||
});
|
||||
|
|
@ -73,4 +74,38 @@ export class MongoDBConversationsRepository implements IConversationsRepository
|
|||
|
||||
return turn;
|
||||
}
|
||||
|
||||
async list(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>> {
|
||||
const query: any = { projectId };
|
||||
|
||||
if (cursor) {
|
||||
query._id = { $lt: new ObjectId(cursor) };
|
||||
}
|
||||
|
||||
const results = await this.collection
|
||||
.find(query)
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit + 1) // Fetch one extra to determine if there's a next page
|
||||
.project<z.infer<typeof ListedConversationItem> & { _id: ObjectId }>({
|
||||
_id: 1,
|
||||
projectId: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})
|
||||
.toArray();
|
||||
|
||||
const hasNextPage = results.length > limit;
|
||||
const items = results.slice(0, limit).map(doc => {
|
||||
const { _id, ...rest } = doc;
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import { z } from "zod";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { db } from "@/app/lib/mongodb";
|
||||
import { IJobsRepository, ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
||||
import { Job } from "@/src/entities/models/job";
|
||||
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
|
||||
import { NotFoundError } from "@/src/entities/errors/common";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
/**
|
||||
* MongoDB document schema for Job.
|
||||
* Excludes the 'id' field as it's represented by MongoDB's '_id'.
|
||||
*/
|
||||
const DocSchema = Job.omit({
|
||||
id: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a new job.
|
||||
*/
|
||||
const createJobSchema = Job.pick({
|
||||
reason: true,
|
||||
projectId: true,
|
||||
input: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating an existing job.
|
||||
*/
|
||||
const updateJobSchema = Job.pick({
|
||||
status: true,
|
||||
output: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* MongoDB implementation of the JobsRepository.
|
||||
*
|
||||
* This repository manages jobs in MongoDB, providing operations for
|
||||
* creating, polling, locking, updating, and releasing jobs for worker processing.
|
||||
*/
|
||||
export class MongoDBJobsRepository implements IJobsRepository {
|
||||
private readonly collection = db.collection<z.infer<typeof DocSchema>>("jobs");
|
||||
|
||||
/**
|
||||
* Creates a new job in the system.
|
||||
*/
|
||||
async create(data: z.infer<typeof createJobSchema>): Promise<z.infer<typeof Job>> {
|
||||
const now = new Date().toISOString();
|
||||
const _id = new ObjectId();
|
||||
|
||||
const doc: z.infer<typeof DocSchema> = {
|
||||
...data,
|
||||
status: "pending" as const,
|
||||
workerId: null,
|
||||
lastWorkerId: null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await this.collection.insertOne({
|
||||
...doc,
|
||||
_id,
|
||||
});
|
||||
|
||||
return {
|
||||
...doc,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a job by its unique identifier.
|
||||
*/
|
||||
async fetch(id: string): Promise<z.infer<typeof Job> | null> {
|
||||
const result = await this.collection.findOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls for the next available job that can be processed by a worker.
|
||||
*/
|
||||
async poll(workerId: string): Promise<z.infer<typeof Job> | null> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Find and update the next available job atomically
|
||||
const result = await this.collection.findOneAndUpdate(
|
||||
{
|
||||
status: "pending",
|
||||
workerId: null,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: "running",
|
||||
workerId,
|
||||
lastWorkerId: workerId,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
sort: { createdAt: 1 }, // Process oldest jobs first
|
||||
returnDocument: "after",
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks a specific job for processing by a worker.
|
||||
*/
|
||||
async lock(id: string, workerId: string): Promise<z.infer<typeof Job>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await this.collection.findOneAndUpdate(
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
status: "pending",
|
||||
workerId: null,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: "running",
|
||||
workerId,
|
||||
lastWorkerId: workerId,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnDocument: "after",
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new JobAcquisitionError(`Job ${id} is already locked or doesn't exist`);
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing job with new status and/or output data.
|
||||
*/
|
||||
async update(id: string, data: z.infer<typeof updateJobSchema>): Promise<z.infer<typeof Job>> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await this.collection.findOneAndUpdate(
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
...data,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnDocument: "after",
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundError(`Job ${id} not found`);
|
||||
}
|
||||
|
||||
const { _id, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a job lock, making it available for other workers.
|
||||
*/
|
||||
async release(id: string): Promise<void> {
|
||||
const result = await this.collection.updateOne(
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
workerId: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
throw new NotFoundError(`Job ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists jobs for a specific project with pagination.
|
||||
*/
|
||||
async list(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
|
||||
const query: any = { projectId };
|
||||
|
||||
if (cursor) {
|
||||
query._id = { $lt: new ObjectId(cursor) };
|
||||
}
|
||||
|
||||
const results = await this.collection
|
||||
.find(query)
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit + 1) // Fetch one extra to determine if there's a next page
|
||||
.project<z.infer<typeof ListedJobItem> & { _id: ObjectId }>({
|
||||
_id: 1,
|
||||
projectId: 1,
|
||||
status: 1,
|
||||
reason: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})
|
||||
.toArray();
|
||||
|
||||
const hasNextPage = results.length > limit;
|
||||
const items = results.slice(0, limit).map(doc => {
|
||||
const { _id, ...rest } = doc;
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { IProjectsRepository } from "@/src/application/repositories/projects.repository.interface";
|
||||
import { Project } from "@/src/entities/models/project";
|
||||
import { projectsCollection } from "@/app/lib/mongodb";
|
||||
import { z } from "zod";
|
||||
|
||||
const docSchema = Project
|
||||
.omit({
|
||||
id: true,
|
||||
})
|
||||
.extend({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export class MongodbProjectsRepository implements IProjectsRepository {
|
||||
async fetch(id: string): Promise<z.infer<typeof docSchema> | null> {
|
||||
const doc = await projectsCollection.findOne({ _id: id });
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
const { _id, ...rest } = doc;
|
||||
return {
|
||||
...rest,
|
||||
id: _id.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
async deleteComposioConnectedAccount(projectId: string, toolkitSlug: string): Promise<boolean> {
|
||||
const result = await projectsCollection.updateOne({ _id: projectId }, { $unset: { [`composioConnectedAccounts.${toolkitSlug}`]: "" } });
|
||||
return result.modifiedCount > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { IPubSubService, Subscription } from "@/src/application/services/pub-sub.service.interface";
|
||||
import { redisClient } from "@/app/lib/redis";
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* Redis implementation of the pub-sub service interface.
|
||||
*
|
||||
* This service uses Redis pub-sub functionality to provide a distributed
|
||||
* messaging system where publishers can send messages to channels and
|
||||
* subscribers can receive messages from those channels.
|
||||
*
|
||||
* Features:
|
||||
* - Distributed messaging across multiple application instances
|
||||
* - Automatic message delivery to all subscribers
|
||||
* - Support for multiple channels
|
||||
* - Asynchronous message handling
|
||||
*/
|
||||
export class RedisPubSubService implements IPubSubService {
|
||||
private subscriptions = new Map<string, Set<(message: string) => void>>();
|
||||
private redisSubscriber: Redis | null = null;
|
||||
|
||||
constructor() {
|
||||
this.setupRedisSubscriber();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the Redis subscriber connection for receiving messages.
|
||||
* This creates a separate Redis connection specifically for subscriptions
|
||||
* to avoid blocking the main Redis client.
|
||||
*/
|
||||
private setupRedisSubscriber(): void {
|
||||
this.redisSubscriber = new Redis(process.env.REDIS_URL || '');
|
||||
|
||||
this.redisSubscriber.on('message', (channel: string, message: string) => {
|
||||
const handlers = this.subscriptions.get(channel);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error(`Error in pub-sub handler for channel ${channel}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.redisSubscriber.on('error', (error: Error) => {
|
||||
console.error('Redis pub-sub subscriber error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a message to a specific channel.
|
||||
*
|
||||
* @param channel - The channel name to publish the message to
|
||||
* @param message - The message content to publish
|
||||
* @returns A promise that resolves when the message has been published
|
||||
* @throws {Error} If the publish operation fails
|
||||
*/
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
try {
|
||||
await redisClient.publish(channel, message);
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish message to channel ${channel}:`, error);
|
||||
throw new Error(`Failed to publish message to channel ${channel}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a channel to receive messages.
|
||||
*
|
||||
* @param channel - The channel name to subscribe to
|
||||
* @param handler - A function that will be called when messages are received
|
||||
* @returns A promise that resolves to a Subscription object
|
||||
* @throws {Error} If the subscribe operation fails
|
||||
*/
|
||||
async subscribe(channel: string, handler: (message: string) => void): Promise<Subscription> {
|
||||
try {
|
||||
// Add handler to local subscriptions map
|
||||
if (!this.subscriptions.has(channel)) {
|
||||
this.subscriptions.set(channel, new Set());
|
||||
}
|
||||
this.subscriptions.get(channel)!.add(handler);
|
||||
|
||||
// Subscribe to the channel in Redis if this is the first handler
|
||||
if (this.subscriptions.get(channel)!.size === 1 && this.redisSubscriber) {
|
||||
await this.redisSubscriber.subscribe(channel);
|
||||
}
|
||||
|
||||
// Return subscription object for cleanup
|
||||
return {
|
||||
unsubscribe: async (): Promise<void> => {
|
||||
await this.unsubscribe(channel, handler);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to subscribe to channel ${channel}:`, error);
|
||||
throw new Error(`Failed to subscribe to channel ${channel}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes a specific handler from a channel.
|
||||
*
|
||||
* @param channel - The channel name to unsubscribe from
|
||||
* @param handler - The handler function to remove
|
||||
*/
|
||||
private async unsubscribe(channel: string, handler: (message: string) => void): Promise<void> {
|
||||
try {
|
||||
const handlers = this.subscriptions.get(channel);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
|
||||
// If no more handlers for this channel, unsubscribe from Redis
|
||||
if (handlers.size === 0) {
|
||||
this.subscriptions.delete(channel);
|
||||
if (this.redisSubscriber) {
|
||||
await this.redisSubscriber.unsubscribe(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to unsubscribe from channel ${channel}:`, error);
|
||||
throw new Error(`Failed to unsubscribe from channel ${channel}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { ICreateComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case";
|
||||
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
|
||||
import { CreateDeploymentSchema } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
data: CreateDeploymentSchema.omit({
|
||||
triggerId: true,
|
||||
logo: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export interface ICreateComposioTriggerDeploymentController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
|
||||
}
|
||||
|
||||
export class CreateComposioTriggerDeploymentController implements ICreateComposioTriggerDeploymentController {
|
||||
private readonly createComposioTriggerDeploymentUseCase: ICreateComposioTriggerDeploymentUseCase;
|
||||
|
||||
constructor({
|
||||
createComposioTriggerDeploymentUseCase,
|
||||
}: {
|
||||
createComposioTriggerDeploymentUseCase: ICreateComposioTriggerDeploymentUseCase,
|
||||
}) {
|
||||
this.createComposioTriggerDeploymentUseCase = createComposioTriggerDeploymentUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, data } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.createComposioTriggerDeploymentUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IDeleteComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
export interface IDeleteComposioTriggerDeploymentController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class DeleteComposioTriggerDeploymentController implements IDeleteComposioTriggerDeploymentController {
|
||||
private readonly deleteComposioTriggerDeploymentUseCase: IDeleteComposioTriggerDeploymentUseCase;
|
||||
|
||||
constructor({
|
||||
deleteComposioTriggerDeploymentUseCase,
|
||||
}: {
|
||||
deleteComposioTriggerDeploymentUseCase: IDeleteComposioTriggerDeploymentUseCase,
|
||||
}) {
|
||||
this.deleteComposioTriggerDeploymentUseCase = deleteComposioTriggerDeploymentUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, projectId, deploymentId } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.deleteComposioTriggerDeploymentUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
projectId,
|
||||
deploymentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IListComposioTriggerDeploymentsUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case";
|
||||
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export interface IListComposioTriggerDeploymentsController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>>;
|
||||
}
|
||||
|
||||
export class ListComposioTriggerDeploymentsController implements IListComposioTriggerDeploymentsController {
|
||||
private readonly listComposioTriggerDeploymentsUseCase: IListComposioTriggerDeploymentsUseCase;
|
||||
|
||||
constructor({
|
||||
listComposioTriggerDeploymentsUseCase,
|
||||
}: {
|
||||
listComposioTriggerDeploymentsUseCase: IListComposioTriggerDeploymentsUseCase,
|
||||
}) {
|
||||
this.listComposioTriggerDeploymentsUseCase = listComposioTriggerDeploymentsUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerDeployment>>>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.listComposioTriggerDeploymentsUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
projectId,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IListComposioTriggerTypesUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case";
|
||||
import { ComposioTriggerType } from "@/src/entities/models/composio-trigger-type";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
|
||||
const inputSchema = z.object({
|
||||
toolkitSlug: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface IListComposioTriggerTypesController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>>;
|
||||
}
|
||||
|
||||
export class ListComposioTriggerTypesController implements IListComposioTriggerTypesController {
|
||||
private readonly listComposioTriggerTypesUseCase: IListComposioTriggerTypesUseCase;
|
||||
|
||||
constructor({
|
||||
listComposioTriggerTypesUseCase,
|
||||
}: {
|
||||
listComposioTriggerTypesUseCase: IListComposioTriggerTypesUseCase,
|
||||
}) {
|
||||
this.listComposioTriggerTypesUseCase = listComposioTriggerTypesUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ComposioTriggerType>>>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { toolkitSlug, cursor } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.listComposioTriggerTypesUseCase.execute({
|
||||
toolkitSlug,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IDeleteComposioConnectedAccountUseCase } from "@/src/application/use-cases/composio/delete-composio-connected-account.use-case";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
toolkitSlug: z.string(),
|
||||
connectedAccountId: z.string(),
|
||||
});
|
||||
|
||||
export interface IDeleteComposioConnectedAccountController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<void>;
|
||||
}
|
||||
|
||||
export class DeleteComposioConnectedAccountController implements IDeleteComposioConnectedAccountController {
|
||||
private readonly deleteComposioConnectedAccountUseCase: IDeleteComposioConnectedAccountUseCase;
|
||||
|
||||
constructor({
|
||||
deleteComposioConnectedAccountUseCase,
|
||||
}: {
|
||||
deleteComposioConnectedAccountUseCase: IDeleteComposioConnectedAccountUseCase,
|
||||
}) {
|
||||
this.deleteComposioConnectedAccountUseCase = deleteComposioConnectedAccountUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<void> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, projectId, toolkitSlug, connectedAccountId } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.deleteComposioConnectedAccountUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
projectId,
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IHandleCompsioWebhookRequestUseCase } from "@/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case";
|
||||
|
||||
const inputSchema = z.object({
|
||||
headers: z.record(z.string(), z.string()),
|
||||
payload: z.string(),
|
||||
});
|
||||
|
||||
export interface IHandleComposioWebhookRequestController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<void>;
|
||||
}
|
||||
|
||||
export class HandleComposioWebhookRequestController implements IHandleComposioWebhookRequestController {
|
||||
private readonly handleCompsioWebhookRequestUseCase: IHandleCompsioWebhookRequestUseCase;
|
||||
|
||||
constructor({
|
||||
handleCompsioWebhookRequestUseCase,
|
||||
}: {
|
||||
handleCompsioWebhookRequestUseCase: IHandleCompsioWebhookRequestUseCase,
|
||||
}) {
|
||||
this.handleCompsioWebhookRequestUseCase = handleCompsioWebhookRequestUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<void> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { headers, payload } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.handleCompsioWebhookRequestUseCase.execute({
|
||||
headers,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IFetchConversationUseCase } from "@/src/application/use-cases/conversations/fetch-conversation.use-case";
|
||||
import { Conversation } from "@/src/entities/models/conversation";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
conversationId: z.string(),
|
||||
});
|
||||
|
||||
export interface IFetchConversationController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>>;
|
||||
}
|
||||
|
||||
export class FetchConversationController implements IFetchConversationController {
|
||||
private readonly fetchConversationUseCase: IFetchConversationUseCase;
|
||||
|
||||
constructor({
|
||||
fetchConversationUseCase,
|
||||
}: {
|
||||
fetchConversationUseCase: IFetchConversationUseCase,
|
||||
}) {
|
||||
this.fetchConversationUseCase = fetchConversationUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, conversationId } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.fetchConversationUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IListConversationsUseCase } from "@/src/application/use-cases/conversations/list-conversations.use-case";
|
||||
import { Conversation } from "@/src/entities/models/conversation";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
import { ListedConversationItem } from "@/src/application/repositories/conversations.repository.interface";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export interface IListConversationsController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>>;
|
||||
}
|
||||
|
||||
export class ListConversationsController implements IListConversationsController {
|
||||
private readonly listConversationsUseCase: IListConversationsUseCase;
|
||||
|
||||
constructor({
|
||||
listConversationsUseCase,
|
||||
}: {
|
||||
listConversationsUseCase: IListConversationsUseCase,
|
||||
}) {
|
||||
this.listConversationsUseCase = listConversationsUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedConversationItem>>>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.listConversationsUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
projectId,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IFetchJobUseCase } from "@/src/application/use-cases/jobs/fetch-job.use-case";
|
||||
import { Job } from "@/src/entities/models/job";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
jobId: z.string(),
|
||||
});
|
||||
|
||||
export interface IFetchJobController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>>;
|
||||
}
|
||||
|
||||
export class FetchJobController implements IFetchJobController {
|
||||
private readonly fetchJobUseCase: IFetchJobUseCase;
|
||||
|
||||
constructor({
|
||||
fetchJobUseCase,
|
||||
}: {
|
||||
fetchJobUseCase: IFetchJobUseCase,
|
||||
}) {
|
||||
this.fetchJobUseCase = fetchJobUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof Job>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, jobId } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.fetchJobUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { BadRequestError } from "@/src/entities/errors/common";
|
||||
import z from "zod";
|
||||
import { IListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
|
||||
import { Job } from "@/src/entities/models/job";
|
||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export interface IListJobsController {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
|
||||
}
|
||||
|
||||
export class ListJobsController implements IListJobsController {
|
||||
private readonly listJobsUseCase: IListJobsUseCase;
|
||||
|
||||
constructor({
|
||||
listJobsUseCase,
|
||||
}: {
|
||||
listJobsUseCase: IListJobsUseCase,
|
||||
}) {
|
||||
this.listJobsUseCase = listJobsUseCase;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
|
||||
// parse input
|
||||
const result = inputSchema.safeParse(request);
|
||||
if (!result.success) {
|
||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
|
||||
|
||||
// execute use case
|
||||
return await this.listJobsUseCase.execute({
|
||||
caller,
|
||||
userId,
|
||||
apiKey,
|
||||
projectId,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue