Merge pull request #215 from rowboatlabs/dev

dev changes
This commit is contained in:
Ramnique Singh 2025-08-23 10:19:27 +05:30 committed by GitHub
commit 6fc6abc2bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 2359 additions and 649 deletions

View file

@ -1,13 +1,15 @@
"use server";
import { auth0 } from "../lib/auth0";
import { USE_AUTH } from "../lib/feature_flags";
import { WithStringId, User } from "../lib/types/types";
import { User } from "@/src/entities/models/user";
import { getUserFromSessionId, GUEST_DB_USER } from "../lib/auth";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { usersCollection } from "../lib/mongodb";
import { container } from "@/di/container";
import { IUsersRepository } from "@/src/application/repositories/users.repository.interface";
export async function authCheck(): Promise<WithStringId<z.infer<typeof User>>> {
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
export async function authCheck(): Promise<z.infer<typeof User>> {
if (!USE_AUTH) {
return GUEST_DB_USER;
}
@ -42,12 +44,5 @@ export async function updateUserEmail(email: string) {
}
// update customer email in db
await usersCollection.updateOne({
_id: new ObjectId(user._id),
}, {
$set: {
email,
updatedAt: new Date().toISOString(),
}
});
await usersRepository.updateEmail(user.id, email);
}

View file

@ -21,9 +21,8 @@ import {
ModelsResponse
} from "../lib/types/billing_types";
import { z } from "zod";
import { WithStringId } from "../lib/types/types";
export async function getCustomer(): Promise<WithStringId<z.infer<typeof Customer>>> {
export async function getCustomer(): Promise<z.infer<typeof Customer>> {
const user = await authCheck();
if (!user.billingCustomerId) {
throw new Error("Customer not found");
@ -41,7 +40,7 @@ export async function authorizeUserAction(request: z.infer<typeof AuthorizeReque
}
const customer = await getCustomer();
const response = await authorize(customer._id, request);
const response = await authorize(customer.id, request);
return response;
}
@ -51,7 +50,7 @@ export async function logUsage(request: z.infer<typeof LogUsageRequest>) {
}
const customer = await getCustomer();
await libLogUsage(customer._id, request);
await libLogUsage(customer.id, request);
return;
}
@ -61,7 +60,7 @@ export async function getCustomerPortalUrl(returnUrl: string): Promise<string> {
}
const customer = await getCustomer();
return await createCustomerPortalSession(customer._id, returnUrl);
return await createCustomerPortalSession(customer.id, returnUrl);
}
export async function getPrices(): Promise<z.infer<typeof PricesResponse>> {
@ -80,7 +79,7 @@ export async function updateSubscriptionPlan(plan: z.infer<typeof SubscriptionPl
const customer = await getCustomer();
const request: z.infer<typeof UpdateSubscriptionPlanRequest> = { plan, returnUrl };
const url = await libUpdateSubscriptionPlan(customer._id, request);
const url = await libUpdateSubscriptionPlan(customer.id, request);
return url;
}
@ -90,6 +89,6 @@ export async function getEligibleModels(): Promise<z.infer<typeof ModelsResponse
}
const customer = await getCustomer();
const response = await libGetEligibleModels(customer._id);
const response = await libGetEligibleModels(customer.id);
return response;
}

View file

@ -49,7 +49,7 @@ export async function listToolkits(projectId: string, cursor: string | null = nu
const user = await authCheck();
return await listComposioToolkitsController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
cursor,
});
@ -59,7 +59,7 @@ export async function getToolkit(projectId: string, toolkitSlug: string): Promis
const user = await authCheck();
return await getComposioToolkitController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
toolkitSlug,
});
@ -69,7 +69,7 @@ export async function listTools(projectId: string, toolkitSlug: string, searchQu
const user = await authCheck();
return await listComposioToolsController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
toolkitSlug,
searchQuery,
@ -81,7 +81,7 @@ export async function createComposioManagedOauth2ConnectedAccount(projectId: str
const user = await authCheck();
return await createComposioManagedConnectedAccountController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
toolkitSlug,
callbackUrl,
@ -92,7 +92,7 @@ export async function createCustomConnectedAccount(projectId: string, request: z
const user = await authCheck();
return await createCustomConnectedAccountController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
toolkitSlug: request.toolkitSlug,
authConfig: request.authConfig,
@ -104,7 +104,7 @@ export async function syncConnectedAccount(projectId: string, toolkitSlug: strin
const user = await authCheck();
return await syncConnectedAccountController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
toolkitSlug,
connectedAccountId,
@ -116,7 +116,7 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str
await deleteComposioConnectedAccountController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
toolkitSlug,
});
@ -144,7 +144,7 @@ export async function createComposioTriggerDeployment(request: {
// create trigger deployment
return await createComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
data: {
triggerTypeSlug: request.triggerTypeSlug,
@ -163,7 +163,7 @@ export async function listComposioTriggerDeployments(request: {
// list trigger deployments
return await listComposioTriggerDeploymentsController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
cursor: request.cursor,
});
@ -178,7 +178,7 @@ export async function deleteComposioTriggerDeployment(request: {
// delete trigger deployment
return await deleteComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
deploymentId: request.deploymentId,
});
@ -188,7 +188,7 @@ export async function fetchComposioTriggerDeployment(request: { deploymentId: st
const user = await authCheck();
return await fetchComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
deploymentId: request.deploymentId,
});
}

View file

@ -17,7 +17,7 @@ export async function listConversations(request: {
return await listConversationsController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
@ -31,7 +31,7 @@ export async function fetchConversation(request: {
return await fetchConversationController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
conversationId: request.conversationId,
});
}

View file

@ -28,7 +28,7 @@ export async function getCopilotResponseStream(
streamId: string;
} | { billingError: string }> {
await projectAuthCheck(projectId);
await usageQuotaPolicy.assertAndConsume(projectId);
await usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Check billing authorization
const authResponse = await authorizeUserAction({
@ -38,7 +38,7 @@ export async function getCopilotResponseStream(
return { billingError: authResponse.error || 'Billing error' };
}
await usageQuotaPolicy.assertAndConsume(projectId);
await usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
@ -70,7 +70,7 @@ export async function getCopilotAgentInstructions(
agentName: string,
): Promise<string | { billingError: string }> {
await projectAuthCheck(projectId);
await usageQuotaPolicy.assertAndConsume(projectId);
await usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Check billing authorization
const authResponse = await authorizeUserAction({

View file

@ -32,7 +32,7 @@ export async function addServer(projectId: string, name: string, server: McpServ
validateUrl(server.serverUrl);
await addCustomMcpServerController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
name,
server,
@ -43,7 +43,7 @@ export async function removeServer(projectId: string, name: string): Promise<voi
const user = await authCheck();
await removeCustomMcpServerController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
name,
});

View file

@ -35,7 +35,7 @@ export async function getDataSource(sourceId: string): Promise<z.infer<typeof Da
return await fetchDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
});
}
@ -45,7 +45,7 @@ export async function listDataSources(projectId: string): Promise<z.infer<typeof
return await listDataSourcesController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
}
@ -66,7 +66,7 @@ export async function createDataSource({
const user = await authCheck();
return await createDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
data: {
projectId,
name,
@ -82,7 +82,7 @@ export async function recrawlWebDataSource(sourceId: string) {
return await recrawlWebDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
});
}
@ -92,7 +92,7 @@ export async function deleteDataSource(sourceId: string) {
return await deleteDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
});
}
@ -102,7 +102,7 @@ export async function toggleDataSource(sourceId: string, active: boolean) {
return await toggleDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
active,
});
@ -122,7 +122,7 @@ export async function addDocsToDataSource({
return await addDocsToDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
docs: docData,
});
@ -144,7 +144,7 @@ export async function listDocsInDataSource({
const docs = await listDocsInDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
});
@ -162,7 +162,7 @@ export async function deleteDocFromDataSource({
const user = await authCheck();
return await deleteDocFromDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
docId,
});
}
@ -174,7 +174,7 @@ export async function getDownloadUrlForFile(
return await getDownloadUrlForFileController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
fileId,
});
}
@ -191,7 +191,7 @@ export async function getUploadUrlsForFilesDataSource(
return await getUploadUrlsForFilesController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
files,
});
@ -208,7 +208,7 @@ export async function updateDataSource({
return await updateDataSourceController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
sourceId,
data: {
description,

View file

@ -20,7 +20,7 @@ export async function listJobs(request: {
return await listJobsController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
filters: request.filters,
cursor: request.cursor,
@ -35,7 +35,7 @@ export async function fetchJob(request: {
return await fetchJobController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
jobId: request.jobId,
});
}

View file

@ -22,7 +22,7 @@ export async function createConversation({
const controller = container.resolve<ICreatePlaygroundConversationController>("createPlaygroundConversationController");
return await controller.execute({
userId: user._id,
userId: user.id,
projectId,
workflow,
isLiveWorkflow,
@ -41,7 +41,7 @@ export async function createCachedTurn({
const { key } = await createCachedTurnController.execute({
caller: "user",
userId: user._id,
userId: user.id,
conversationId,
input: {
messages,

View file

@ -57,7 +57,7 @@ export async function projectAuthCheck(projectId: string) {
const user = await authCheck();
await projectActionAuthorizationPolicy.authorize({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
}
@ -69,7 +69,7 @@ export async function createProject(formData: FormData): Promise<{ id: string }
try {
const project = await createProjectController.execute({
userId: user._id,
userId: user.id,
data: {
name: name || '',
mode: {
@ -94,7 +94,7 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
try {
const project = await createProjectController.execute({
userId: user._id,
userId: user.id,
data: {
name: name || '',
mode: {
@ -116,7 +116,7 @@ export async function fetchProject(projectId: string): Promise<z.infer<typeof Pr
const user = await authCheck();
const project = await fetchProjectController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
@ -134,7 +134,7 @@ export async function listProjects(): Promise<z.infer<typeof Project>[]> {
let cursor = undefined;
do {
const result = await listProjectsController.execute({
userId: user._id,
userId: user.id,
cursor,
});
projects.push(...result.items);
@ -148,7 +148,7 @@ export async function rotateSecret(projectId: string): Promise<string> {
const user = await authCheck();
return await rotateSecretController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
}
@ -157,7 +157,7 @@ export async function updateWebhookUrl(projectId: string, url: string) {
const user = await authCheck();
await updateWebhookUrlController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
url,
});
@ -167,7 +167,7 @@ export async function createApiKey(projectId: string): Promise<z.infer<typeof Ap
const user = await authCheck();
return await createApiKeyController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
}
@ -176,7 +176,7 @@ export async function deleteApiKey(projectId: string, id: string) {
const user = await authCheck();
return await deleteApiKeyController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
id,
});
@ -186,7 +186,7 @@ export async function listApiKeys(projectId: string): Promise<z.infer<typeof Api
const user = await authCheck();
return await listApiKeysController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
}
@ -195,7 +195,7 @@ export async function updateProjectName(projectId: string, name: string) {
const user = await authCheck();
await updateProjectNameController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
name,
});
@ -205,7 +205,7 @@ export async function deleteProject(projectId: string) {
const user = await authCheck();
await deleteProjectController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
@ -216,7 +216,7 @@ export async function saveWorkflow(projectId: string, workflow: z.infer<typeof W
const user = await authCheck();
await updateDraftWorkflowController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
workflow,
});
@ -226,7 +226,7 @@ export async function publishWorkflow(projectId: string, workflow: z.infer<typeo
const user = await authCheck();
await updateLiveWorkflowController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
workflow,
});
@ -236,7 +236,7 @@ export async function revertToLiveWorkflow(projectId: string) {
const user = await authCheck();
await revertToLiveWorkflowController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId,
});
}

View file

@ -27,7 +27,7 @@ export async function createRecurringJobRule(request: {
return await createRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
input: request.input,
cron: request.cron,
@ -43,7 +43,7 @@ export async function listRecurringJobRules(request: {
return await listRecurringJobRulesController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
@ -57,7 +57,7 @@ export async function fetchRecurringJobRule(request: {
return await fetchRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
ruleId: request.ruleId,
});
}
@ -70,7 +70,7 @@ export async function toggleRecurringJobRule(request: {
return await toggleRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
ruleId: request.ruleId,
disabled: request.disabled,
});
@ -84,7 +84,7 @@ export async function deleteRecurringJobRule(request: {
return await deleteRecurringJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
});

View file

@ -25,7 +25,7 @@ export async function createScheduledJobRule(request: {
return await createScheduledJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
input: request.input,
scheduledTime: request.scheduledTime,
@ -41,7 +41,7 @@ export async function listScheduledJobRules(request: {
return await listScheduledJobRulesController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
cursor: request.cursor,
limit: request.limit,
@ -55,7 +55,7 @@ export async function fetchScheduledJobRule(request: {
return await fetchScheduledJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
ruleId: request.ruleId,
});
}
@ -68,7 +68,7 @@ export async function deleteScheduledJobRule(request: {
return await deleteScheduledJobRuleController.execute({
caller: 'user',
userId: user._id,
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
});

View file

@ -53,7 +53,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
}
} catch (error) {
console.error('Error processing copilot stream:', error);
controller.error(error);
controller.error(new Error("Something went wrong. Please try again."));
} finally {
// log copilot usage
if (USE_BILLING && billingCustomerId) {

View file

@ -22,7 +22,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
// Iterate over the generator
for await (const event of runCachedTurnController.execute({
caller: "user",
userId: user._id,
userId: user.id,
cachedTurnKey: params.streamId,
})) {
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(event)}\n\n`));
@ -31,7 +31,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
console.error('Error processing stream:', error);
const errMessage: z.infer<typeof TurnEvent> = {
type: "error",
error: `Error processing stream: ${error}`,
error: "Something went wrong. Please try again.",
isBillingError: false,
};
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(errMessage)}\n\n`));

View file

@ -55,7 +55,7 @@ export async function POST(
controller.close();
} catch (error) {
logger.log(`Error processing stream: ${error}`);
controller.error(error);
controller.error(new Error("Something went wrong. Please try again."));
}
},
});

View file

@ -8,7 +8,6 @@ import { z } from "zod";
import { tokens } from "@/app/styles/design-tokens";
import { SectionHeading } from "@/components/ui/section-heading";
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { WithStringId } from "@/app/lib/types/types";
import clsx from 'clsx';
import { getCustomerPortalUrl } from "../actions/billing.actions";
import { useState } from "react";
@ -31,7 +30,7 @@ const planDetails = {
};
interface BillingPageProps {
customer: WithStringId<z.infer<typeof Customer>>;
customer: z.infer<typeof Customer>;
usage: z.infer<typeof UsageResponse>;
}

View file

@ -13,7 +13,7 @@ export default async function Page(
) {
const searchParams = await props.searchParams;
const customer = await requireBillingCustomer();
await syncWithStripe(customer._id);
await syncWithStripe(customer.id);
const redirectUrl = searchParams.redirect as string;
redirect(redirectUrl || '/projects');
}

View file

@ -12,6 +12,6 @@ export default async function Page() {
}
const customer = await requireBillingCustomer();
const usage = await getUsage(customer._id);
const usage = await getUsage(customer.id);
return <BillingPage customer={customer} usage={usage} />;
}

View file

@ -1,10 +1,10 @@
import { z } from "zod";
import { ObjectId } from "mongodb";
import { usersCollection } from "./mongodb";
import { auth0 } from "./auth0";
import { User, WithStringId } from "./types/types";
import { User } from "@/src/entities/models/user";
import { USE_AUTH } from "./feature_flags";
import { redirect } from "next/navigation";
import { container } from "@/di/container";
import { IUsersRepository } from "@/src/application/repositories/users.repository.interface";
export const GUEST_SESSION = {
email: "guest@rowboatlabs.com",
@ -12,13 +12,12 @@ export const GUEST_SESSION = {
sub: "guest_user",
}
export const GUEST_DB_USER: WithStringId<z.infer<typeof User>> = {
_id: "guest_user",
export const GUEST_DB_USER: z.infer<typeof User> = {
id: "guest_user",
auth0Id: "guest_user",
name: "Guest",
email: "guest@rowboatlabs.com",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
/**
@ -33,7 +32,7 @@ export const GUEST_DB_USER: WithStringId<z.infer<typeof User>> = {
* const user = await requireAuth();
* ```
*/
export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>> {
export async function requireAuth(): Promise<z.infer<typeof User>> {
if (!USE_AUTH) {
return GUEST_DB_USER;
}
@ -44,48 +43,26 @@ export async function requireAuth(): Promise<WithStringId<z.infer<typeof User>>>
}
// fetch db user
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
let dbUser = await getUserFromSessionId(user.sub);
// if db user does not exist, create one
if (!dbUser) {
// create user record
const doc = {
_id: new ObjectId(),
dbUser = await usersRepository.create({
auth0Id: user.sub,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
email: user.email,
};
console.log(`creating new user id ${doc._id.toString()} for session id ${user.sub}`);
await usersCollection.insertOne(doc);
dbUser = {
...doc,
_id: doc._id.toString(),
};
});
console.log(`created new user id ${dbUser.id} for session id ${user.sub}`);
}
const { _id, ...rest } = dbUser;
return {
...rest,
_id: _id.toString(),
};
return dbUser;
}
export async function getUserFromSessionId(sessionUserId: string): Promise<WithStringId<z.infer<typeof User>> | null> {
export async function getUserFromSessionId(sessionUserId: string): Promise<z.infer<typeof User> | null> {
if (!USE_AUTH) {
return GUEST_DB_USER;
}
let dbUser = await usersCollection.findOne({
auth0Id: sessionUserId
});
if (!dbUser) {
return null;
}
const { _id, ...rest } = dbUser;
return {
...rest,
_id: _id.toString(),
};
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
return await usersRepository.fetchByAuth0Id(sessionUserId);
}

View file

@ -1,13 +1,11 @@
import { WithStringId } from './types/types';
import { z } from 'zod';
import { Customer, AuthorizeRequest, AuthorizeResponse, LogUsageRequest, UsageResponse, CustomerPortalSessionResponse, PricesResponse, UpdateSubscriptionPlanRequest, UpdateSubscriptionPlanResponse, ModelsResponse, UsageItem } from './types/billing_types';
import { ObjectId } from 'mongodb';
import { usersCollection } from './mongodb';
import { redirect } from 'next/navigation';
import { getUserFromSessionId, requireAuth } from './auth';
import { USE_BILLING } from './feature_flags';
import { container } from '@/di/container';
import { IProjectsRepository } from '@/src/application/repositories/projects.repository.interface';
import { IUsersRepository } from '@/src/application/repositories/users.repository.interface';
const BILLING_API_URL = process.env.BILLING_API_URL || 'http://billing';
const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test';
@ -15,7 +13,7 @@ const BILLING_API_KEY = process.env.BILLING_API_KEY || 'test';
let logCounter = 1;
const GUEST_BILLING_CUSTOMER = {
_id: "guest-user",
id: "guest-user",
userId: "guest-user",
name: "Guest",
email: "guest@rowboatlabs.com",
@ -24,9 +22,9 @@ const GUEST_BILLING_CUSTOMER = {
subscriptionPlan: "free" as const,
subscriptionStatus: "active" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
export class UsageTracker{
private items: z.infer<typeof UsageItem>[] = [];
@ -41,8 +39,10 @@ export class UsageTracker{
}
}
export async function getCustomerForUserId(userId: string): Promise<WithStringId<z.infer<typeof Customer>> | null> {
const user = await usersCollection.findOne({ _id: new ObjectId(userId) });
export async function getCustomerForUserId(userId: string): Promise<z.infer<typeof Customer> | null> {
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
const user = await usersRepository.fetch(userId);
if (!user) {
throw new Error("User not found");
}
@ -62,10 +62,10 @@ export async function getCustomerIdForProject(projectId: string): Promise<string
if (!customer) {
throw new Error("User has no billing customer id");
}
return customer._id;
return customer.id;
}
export async function getBillingCustomer(id: string): Promise<WithStringId<z.infer<typeof Customer>> | null> {
export async function getBillingCustomer(id: string): Promise<z.infer<typeof Customer> | null> {
const response = await fetch(`${BILLING_API_URL}/api/customers/${id}`, {
method: 'GET',
headers: {
@ -84,7 +84,7 @@ export async function getBillingCustomer(id: string): Promise<WithStringId<z.inf
return parseResult.data;
}
async function createBillingCustomer(userId: string, email: string): Promise<WithStringId<z.infer<typeof Customer>>> {
async function createBillingCustomer(userId: string, email: string): Promise<z.infer<typeof Customer>> {
const response = await fetch(`${BILLING_API_URL}/api/customers`, {
method: 'POST',
headers: {
@ -264,13 +264,14 @@ export async function getEligibleModels(customerId: string): Promise<z.infer<typ
* const billingCustomer = await requireBillingCustomer();
* ```
*/
export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typeof Customer>>> {
export async function requireBillingCustomer(): Promise<z.infer<typeof Customer>> {
const user = await requireAuth();
const usersRepository = container.resolve<IUsersRepository>("usersRepository");
if (!USE_BILLING) {
return {
...GUEST_BILLING_CUSTOMER,
userId: user._id,
userId: user.id,
};
}
@ -280,22 +281,15 @@ export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typ
}
// fetch or create customer
let customer: WithStringId<z.infer<typeof Customer>> | null;
let customer: z.infer<typeof Customer> | null;
if (user.billingCustomerId) {
customer = await getBillingCustomer(user.billingCustomerId);
} else {
customer = await createBillingCustomer(user._id, user.email);
console.log("created billing customer", JSON.stringify({ userId: user._id, customer }));
customer = await createBillingCustomer(user.id, user.email);
console.log("created billing customer", JSON.stringify({ userId: user.id, customer }));
// update customer id in db
await usersCollection.updateOne({
_id: new ObjectId(user._id),
}, {
$set: {
billingCustomerId: customer._id,
updatedAt: new Date().toISOString(),
}
});
await usersRepository.updateBillingCustomerId(user.id, customer.id);
}
if (!customer) {
throw new Error("Failed to fetch or create billing customer");
@ -316,12 +310,11 @@ export async function requireBillingCustomer(): Promise<WithStringId<z.infer<typ
* const billingCustomer = await requireActiveBillingSubscription();
* ```
*/
export async function requireActiveBillingSubscription(): Promise<WithStringId<z.infer<typeof Customer>>> {
export async function requireActiveBillingSubscription(): Promise<z.infer<typeof Customer>> {
const billingCustomer = await requireBillingCustomer();
if (USE_BILLING && billingCustomer.subscriptionStatus !== "active" && billingCustomer.subscriptionStatus !== "past_due") {
redirect('/billing');
}
return billingCustomer;
}
}

View file

@ -1,9 +1,11 @@
import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types";
import { z } from "zod";
const ZFallbackSchema = z.object({}).passthrough();
export function validateConfigChanges(configType: string, configChanges: Record<string, unknown>, name: string) {
let testObject: any;
let schema: z.ZodType<any>;
let schema: z.ZodType<any> = ZFallbackSchema;
switch (configType) {
case 'tool': {
@ -56,6 +58,10 @@ export function validateConfigChanges(configType: string, configChanges: Record<
schema = WorkflowPipeline;
break;
}
case 'start_agent': {
testObject = {};
break;
}
default:
return { error: `Unknown config type: ${configType}` };
}

View file

@ -55,11 +55,15 @@ export function createAtMentions({ agents, prompts, tools, pipelines = [], curre
// Add prompts (always allowed)
for (const prompt of prompts) {
const id = `prompt:${prompt.name}`;
// Use 'variable' for base_prompt types, 'prompt' for others
const isVariable = prompt.type === 'base_prompt';
const type = isVariable ? 'variable' : 'prompt';
const label = isVariable ? 'Variable' : 'Prompt';
const id = `${type}:${prompt.name}`;
atMentions.push({
id,
value: id,
label: `Prompt: ${prompt.name}`,
label: `${label}: ${prompt.name}`,
denotationChar: "@",
link: id,
target: "_self"

View file

@ -15,6 +15,6 @@ export const USE_VOICE_FEATURE = false;
export const USE_TRANSFER_CONTROL_OPTIONS = false;
export const USE_PRODUCT_TOUR = false;
export const SHOW_COPILOT_MARQUEE = false;
export const SHOW_PROMPTS_SECTION = false;
export const SHOW_PROMPTS_SECTION = true;
export const SHOW_DARK_MODE_TOGGLE = false;
export const SHOW_VISUALIZATION = false

View file

@ -1,5 +1,4 @@
import { MongoClient } from "mongodb";
import { User } from "./types/types";
import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
import { z } from 'zod';
import { apiV1 } from "rowboat-shared";
@ -10,7 +9,6 @@ export const db = client.db("rowboat");
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
export const usersCollection = db.collection<z.infer<typeof User>>("users");
export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls");
// Create indexes

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,17 @@
/**
* 🚨 ATTENTION: DO NOT MODIFY THIS FILE! 🚨
*
* This file contains billing types that are manually copied
* from the billing service repository. Any manual changes will be
* overwritten during the next sync.
*
* If you need to modify billing types:
* 1. Make changes in the billing service repo
* 2. Copy the updated file from there
* 3. Never edit this file directly
*
* This file is a manual copy - keep it in sync with the source!
*/
import { z } from "zod";
export const SubscriptionPlan = z.enum(["free", "starter", "pro"]);
@ -57,7 +71,7 @@ export const LogUsageRequest = z.object({
export const CustomerUsageData = z.record(z.string(), z.number());
export const Customer = z.object({
_id: z.string(),
id: z.string(),
userId: z.string(),
email: z.string(),
stripeCustomerId: z.string(),
@ -65,7 +79,7 @@ export const Customer = z.object({
subscriptionPlan: SubscriptionPlan.optional(),
subscriptionStatus: z.enum([ 'active', 'past_due' ]).optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
updatedAt: z.string().datetime().optional(),
subscriptionPlanUpdatedAt: z.string().datetime().optional(),
usage: CustomerUsageData.optional(),
usageUpdatedAt: z.string().datetime().optional(),

View file

@ -124,15 +124,6 @@ export const McpServerResponse = z.object({
error: z.string().nullable(),
});
export const User = z.object({
auth0Id: z.string(),
billingCustomerId: z.string().optional(),
name: z.string().optional(),
email: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const Webpage = z.object({
_id: z.string(),
title: z.string(),

View file

@ -129,8 +129,8 @@ export function sanitizeTextWithMentions(
sanitized: string;
entities: z.infer<typeof ConnectedEntity>[];
} {
// Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent/pipeline
const mentionRegex = /\[@(tool|prompt|agent|pipeline):([^\]]+)\]\(#mention\)/g;
// Regex to match [@type:name](#type:something) pattern where type is tool/prompt/agent/pipeline/variable
const mentionRegex = /\[@(tool|prompt|agent|pipeline|variable):([^\]]+)\]\(#mention\)/g;
const seen = new Set<string>();
// collect entities
@ -144,8 +144,10 @@ export function sanitizeTextWithMentions(
return true;
})
.map(match => {
// Treat @variable: as @prompt: internally
const type = match[1] === 'variable' ? 'prompt' : match[1];
return {
type: match[1] as 'tool' | 'prompt' | 'agent' | 'pipeline',
type: type as 'tool' | 'prompt' | 'agent' | 'pipeline',
name: match[2],
};
})
@ -176,6 +178,12 @@ export function sanitizeTextWithMentions(
const id = `${entity.type}:${entity.name}`;
const textToReplace = `[@${id}](#mention)`;
text = text.replace(textToReplace, `[@${id}]`);
// Also handle @variable: mentions for prompts
if (entity.type === 'prompt') {
const variableTextToReplace = `[@variable:${entity.name}](#mention)`;
text = text.replace(variableTextToReplace, `[@variable:${entity.name}]`);
}
}
return {

View file

@ -14,6 +14,9 @@ import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon, Sparkles } from "lucide-react";
import { useCopilot } from "./use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { SHOW_COPILOT_MARQUEE } from "@/app/lib/feature_flags";
import Image from "next/image";
import mascot from "@/public/mascot.png";
const CopilotContext = createContext<{
workflow: z.infer<typeof Workflow> | null;
@ -205,6 +208,56 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
<CopilotContext.Provider value={{ workflow: workflowRef.current, dispatch }}>
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 pointer-events-none">
{/* Replace Sparkles icon with mascot image */}
<Image src={mascot} alt="Rowboat Mascot" width={160} height={160} className="object-contain mb-3 animate-float" />
{/* Welcome/Intro Section */}
<div className="text-center max-w-md px-6 mb-3">
<h3 className="text-xl font-semibold text-zinc-700 dark:text-zinc-300 mb-2 text-center">
👋 Hi there!
</h3>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-4 text-center">
I&apos;m your copilot for building agents and adding tools to them.
</p>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-3 text-center">
Here&apos;s what you can do in Rowboat:
</p>
<div className="space-y-2 max-w-2xl mx-auto text-left">
<div className="flex items-start gap-3">
<span className="text-lg"></span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Build AI agents instantly with natural language.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔌</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Connect tools with one-click integrations.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">📂</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Power with knowledge by adding documents for RAG.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔄</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Automate workflows by setting up triggers and actions.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🚀</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Deploy anywhere via API or SDK.</span>
</div>
</div>
</div>
{SHOW_COPILOT_MARQUEE && (
<div className="relative mt-2 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
</div>
</div>
)}
</div>
)}
<Messages
messages={messages}
streamingResponse={streamingResponse}
@ -320,7 +373,6 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
<Panel
variant="copilot"
tourTarget="copilot"
showWelcome={messages.length === 0}
icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />}
title="Skipper"
subtitle="Build your assistant"

View file

@ -51,9 +51,12 @@ export function Action({
const appliedFields = Object.keys(action.config_changes).filter(key =>
appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)]
);
const allApplied = externallyApplied || Object.keys(action.config_changes).every(key =>
let allApplied = externallyApplied || Object.keys(action.config_changes).every(key =>
appliedFields.includes(key)
);
if (!externallyApplied && (action.action === "delete" || action.config_type === 'start_agent')) {
allApplied = false;
}
// Handle applying a single field change
const handleFieldChange = (field: string) => {
@ -160,7 +163,8 @@ export function Action({
'transition-shadow duration-150',
{
'border-l-2 border-l-blue-500': !stale && !allApplied && action.action == 'create_new',
'border-l-2 border-l-orange-500': !stale && !allApplied && action.action == 'edit',
'border-l-2 border-l-yellow-500': !stale && !allApplied && action.action == 'edit',
'border-l-2 border-l-red-500': !stale && !allApplied && action.action == 'delete',
'border-l-2 border-l-gray-400': stale || allApplied || action.error,
}
)}>
@ -171,14 +175,15 @@ export function Action({
'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs',
{
'bg-blue-100 text-blue-600': action.action == 'create_new',
'bg-orange-100 text-orange-600': action.action == 'edit',
'bg-yellow-100 text-yellow-600': action.action == 'edit',
'bg-red-100 text-red-600': action.action == 'delete',
'bg-gray-200 text-gray-600': stale || allApplied || action.error,
}
)}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : '💬'}
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : '💬'}
</span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name}
{action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
</span>
{/* Action buttons - compact, icon only, show text on hover */}
<div className="flex items-center gap-1">
@ -195,13 +200,13 @@ export function Action({
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
<span>{allApplied ? 'Applied' : 'Apply'}</span>
</button>
<button
{action.action !== 'delete' && <button
className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors"
onClick={handleViewDiff}
>
<EyeIcon size={13} className="text-indigo-600 group-hover:text-indigo-700" />
<span>View Diff</span>
</button>
</button>}
</div>
</div>
{/* Description of what happened */}
@ -341,8 +346,8 @@ export function StreamingAction({
loading,
}: {
action: {
action?: 'create_new' | 'edit';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline';
action?: 'create_new' | 'edit' | 'delete';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent';
name?: string;
};
loading: boolean;
@ -354,7 +359,8 @@ export function StreamingAction({
'transition-shadow duration-150',
{
'border-l-2 border-l-blue-500': action.action == 'create_new',
'border-l-2 border-l-orange-500': action.action == 'edit',
'border-l-2 border-l-yellow-500': action.action == 'edit',
'border-l-2 border-l-red-500': action.action == 'delete',
'border-l-2 border-l-gray-400': !action.action,
}
)}>
@ -364,14 +370,15 @@ export function StreamingAction({
'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs',
{
'bg-blue-100 text-blue-600': action.action == 'create_new',
'bg-orange-100 text-orange-600': action.action == 'edit',
'bg-yellow-100 text-yellow-600': action.action == 'edit',
'bg-red-100 text-red-600': action.action == 'delete',
'bg-gray-200 text-gray-600': !action.action,
}
)}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : '💬'}
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : '💬'}
</span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name}
{action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
</span>
</div>
{/* Loading state body */}

View file

@ -70,8 +70,8 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
return {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline',
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: {},
@ -83,8 +83,8 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
return {
type: 'action',
action: {
action: metadata.action as 'create_new' | 'edit',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline',
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
@ -99,8 +99,8 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
return {
type: 'streaming_action',
action: {
action: (metadata.action as 'create_new' | 'edit') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline') || undefined,
action: (metadata.action as 'create_new' | 'edit' | 'delete') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent') || undefined,
name: metadata.name
}
};
@ -289,6 +289,39 @@ function AssistantMessage({
pipeline: action.config_changes
});
break;
case 'start_agent':
dispatch({
type: 'set_main_agent',
name: action.name,
})
break;
}
} else if (action.action === 'delete') {
switch (action.config_type) {
case 'agent':
dispatch({
type: 'delete_agent',
name: action.name
});
break;
case 'tool':
dispatch({
type: 'delete_tool',
name: action.name
});
break;
case 'prompt':
dispatch({
type: 'delete_prompt',
name: action.name
});
break;
case 'pipeline':
dispatch({
type: 'delete_pipeline',
name: action.name
});
break;
}
}
}, [dispatch, workflow.agents, workflow.tools]);
@ -542,7 +575,7 @@ export function Messages({
};
return (
<div className="h-full">
<div className={displayMessages.length === 0 ? "" : "h-full"}>
<div className="flex flex-col mb-4">
{displayMessages.map((message, index) => (
<div key={index} className="mb-4">

View file

@ -2,10 +2,10 @@
import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source";
import { z } from "zod";
import { PlusIcon, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon, DatabaseIcon, UserIcon, Settings, Info } from "lucide-react";
import { PlusIcon, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon, DatabaseIcon, UserIcon, Settings, Info, Edit3 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "../workflow/preview-modal";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem, Chip, SelectSection } from "@heroui/react";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem, Chip, SelectSection, Input } from "@heroui/react";
import { PreviewModalProvider } from "../workflow/preview-modal";
import { CopilotMessage } from "@/src/application/lib/copilot/types";
import { getCopilotAgentInstructions } from "@/app/actions/copilot.actions";
@ -17,7 +17,6 @@ import { Button as CustomButton } from "@/components/ui/button";
import clsx from "clsx";
import { InputField } from "@/app/lib/components/input-field";
import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
import { Input } from "@/components/ui/input";
import { Info as InfoIcon } from "lucide-react";
import { useCopilot } from "../copilot/use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
@ -78,6 +77,8 @@ export function AgentConfig({
const [previousRagSources, setPreviousRagSources] = useState<string[]>([]);
const [billingError, setBillingError] = useState<string | null>(null);
const [showSavedBanner, setShowSavedBanner] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
// Check if this agent is a pipeline agent
const isPipelineAgent = agent.type === 'pipeline';
@ -101,6 +102,14 @@ export function AgentConfig({
setLocalName(agent.name);
}, [agent.name]);
// Focus name input when entering edit mode
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [isEditingName]);
// Track changes in RAG datasources
useEffect(() => {
const currentSources = agent.ragDataSources || [];
@ -149,6 +158,13 @@ export function AgentConfig({
}
}, [agent.controlType, agent.outputVisibility, agent, handleUpdate]);
// Add effect to ensure internal agents have maxCallsPerParentAgent set to 1 by default
useEffect(() => {
if (agent.outputVisibility === "internal" && !isPipelineAgent && agent.maxCallsPerParentAgent === undefined) {
handleUpdate({ ...agent, maxCallsPerParentAgent: 1 });
}
}, [agent.outputVisibility, agent.maxCallsPerParentAgent, agent, handleUpdate, isPipelineAgent]);
// Add effect to handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@ -188,15 +204,36 @@ export function AgentConfig({
return true;
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setLocalName(newName);
if (validateName(newName)) {
setNameError(null);
};
const handleNameCommit = () => {
if (validateName(localName)) {
handleUpdate({
...agent,
name: newName
name: localName
});
showSavedMessage();
setIsEditingName(false);
}
};
const handleNameCancel = () => {
setLocalName(agent.name);
setNameError(null);
setIsEditingName(false);
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleNameCommit();
} else if (e.key === 'Escape') {
e.preventDefault();
handleNameCancel();
}
};
@ -209,20 +246,42 @@ export function AgentConfig({
currentAgent: agent
});
// Add local state for max calls input
const [maxCallsInput, setMaxCallsInput] = useState(String(agent.maxCallsPerParentAgent || 3));
const [maxCallsError, setMaxCallsError] = useState<string | null>(null);
// Sync local state with agent prop
useEffect(() => {
setMaxCallsInput(String(agent.maxCallsPerParentAgent || 3));
}, [agent.maxCallsPerParentAgent]);
return (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">
{agent.name}
<div className="flex items-center gap-2 flex-1 min-w-0">
{isEditingName ? (
<div className="flex flex-col min-w-0 flex-1">
<Input
ref={nameInputRef}
type="text"
value={localName}
onChange={handleNameChange}
onKeyDown={handleNameKeyDown}
onBlur={handleNameCommit}
isInvalid={!!nameError}
errorMessage={nameError}
variant="bordered"
size="sm"
classNames={{
base: "max-w-xs",
input: "text-base font-semibold px-2",
inputWrapper: "min-h-[28px] h-[28px] border-gray-200 dark:border-gray-700 px-0"
}}
/>
</div>
) : (
<button
onClick={() => setIsEditingName(true)}
className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 px-2 py-1 rounded-md transition-colors group"
>
<span className="truncate">{agent.name}</span>
<Edit3 className="w-4 h-4 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" />
</button>
)}
</div>
<CustomButton
variant="secondary"
@ -260,7 +319,7 @@ export function AgentConfig({
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab === 'instructions' ? 'Instructions' : 'Model & RAG'}
</button>
))}
</div>
@ -480,38 +539,6 @@ export function AgentConfig({
{activeTab === 'configurations' && (
<div className="flex flex-col gap-4 pb-4 pt-0">
{/* Identity Section Card */}
<SectionCard
icon={<UserIcon className="w-5 h-5 text-indigo-500" />}
title="Identity"
labelWidth="md:w-32"
className="mb-1"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Name</label>
<div className="flex-1">
<InputField
type="text"
value={localName}
onChange={(value) => {
setLocalName(value);
if (validateName(value)) {
handleUpdate({
...agent,
name: value
});
}
showSavedMessage();
}}
error={nameError}
className="w-full"
/>
</div>
</div>
</div>
</SectionCard>
{/* Behavior Section Card */}
<SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />}
@ -618,43 +645,7 @@ export function AgentConfig({
}
</div>
</div>
{agent.outputVisibility === "internal" && !isPipelineAgent && (
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Max Calls From Parent</label>
<div className="flex-1">
<InputField
type="number"
value={maxCallsInput}
onChange={(value: string) => {
setMaxCallsInput(value);
setMaxCallsError(null);
const num = Number(value);
if (value && !isNaN(num) && num >= 1 && Number.isInteger(num)) {
if (num !== agent.maxCallsPerParentAgent) {
handleUpdate({
...agent,
maxCallsPerParentAgent: num
});
}
}
}}
validate={(value: string) => {
const num = Number(value);
if (!value || isNaN(num) || num < 1 || !Number.isInteger(num)) {
return { valid: false, errorMessage: "Must be an integer >= 1" };
}
return { valid: true };
}}
error={maxCallsError}
min={1}
className="w-full max-w-24"
/>
{maxCallsError && (
<p className="text-sm text-red-500 mt-1">{maxCallsError}</p>
)}
</div>
</div>
)}
{USE_TRANSFER_CONTROL_OPTIONS && !isPipelineAgent && (
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">After Turn</label>

View file

@ -14,6 +14,9 @@ const sectionHeaderStyles = "block text-xs font-medium uppercase tracking-wider
// Enhanced textarea styles with improved states
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
// Value field styles without grey placeholder text
const valueTextareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-black dark:placeholder:text-white";
export function PromptConfig({
prompt,
agents,
@ -128,7 +131,7 @@ export function PromptConfig({
<div className="space-y-4">
<label className={sectionHeaderStyles}>
Prompt
Value
</label>
<Textarea
value={prompt.prompt}
@ -139,8 +142,8 @@ export function PromptConfig({
});
showSavedMessage();
}}
placeholder="Edit prompt here..."
className={`${textareaStyles} min-h-[200px]`}
placeholder="Enter variable value..."
className={`${valueTextareaStyles} min-h-[200px]`}
autoResize
/>
</div>

View file

@ -160,6 +160,16 @@ export function Chat({
setIsLastInteracted(true);
}
// clean up event source on component unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
}, []);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;

View file

@ -63,6 +63,9 @@ interface EntityListProps {
onAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void;
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
onAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onUpdatePrompt: (name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onAddPromptFromModal: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onUpdatePromptFromModal: (name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onAddPipeline: (pipeline: Partial<z.infer<typeof WorkflowPipeline>>) => void;
onAddAgentToPipeline: (pipelineName: string) => void;
onToggleAgent: (name: string) => void;
@ -97,6 +100,7 @@ const EmptyState: React.FC<EmptyStateProps> = ({ entity, hasFilteredItems }) =>
const ListItemWithMenu = ({
name,
value,
isSelected,
onClick,
disabled,
@ -110,6 +114,7 @@ const ListItemWithMenu = ({
isMocked,
}: {
name: string;
value?: string;
isSelected?: boolean;
onClick?: () => void;
disabled?: boolean;
@ -157,9 +162,34 @@ const ListItemWithMenu = ({
/>
) : icon}
</div>
<span className="text-xs">{name}</span>
{value ? (
<div className="flex-1 min-w-0 grid grid-cols-2 gap-2">
<Tooltip
content={name}
size="sm"
delay={500}
isDisabled={name.length <= 20}
>
<span className="text-xs font-medium truncate">
{name}
</span>
</Tooltip>
<Tooltip
content={value}
size="sm"
delay={500}
isDisabled={value.length <= 30}
>
<span className="text-xs text-zinc-600 dark:text-zinc-400 truncate">
{value}
</span>
</Tooltip>
</div>
) : (
<span className="text-xs">{name}</span>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
{statusLabel}
{isMocked && (
<Tooltip content="Mocked" size="sm" delay={500}>
@ -168,7 +198,9 @@ const ListItemWithMenu = ({
</div>
</Tooltip>
)}
{menuContent}
<div className="opacity-100">
{menuContent}
</div>
</div>
</div>
);
@ -464,6 +496,9 @@ export const EntityList = forwardRef<
onAddAgent,
onAddTool,
onAddPrompt,
onUpdatePrompt,
onAddPromptFromModal,
onUpdatePromptFromModal,
onAddPipeline,
onAddAgentToPipeline,
onToggleAgent,
@ -490,6 +525,8 @@ export const EntityList = forwardRef<
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [showToolsModal, setShowToolsModal] = useState(false);
const [showDataSourcesModal, setShowDataSourcesModal] = useState(false);
const [showAddVariableModal, setShowAddVariableModal] = useState(false);
const [editingVariable, setEditingVariable] = useState<{name: string; value: string} | null>(null);
// State to track which toolkit's tools panel to open
const [selectedToolkitSlug, setSelectedToolkitSlug] = useState<string | null>(null);
@ -498,6 +535,11 @@ export const EntityList = forwardRef<
outputVisibility: agentType
});
};
const handleVariableClick = (prompt: z.infer<typeof WorkflowPrompt>) => {
setEditingVariable({ name: prompt.name, value: prompt.prompt });
setShowAddVariableModal(true);
};
const selectedRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(0);
@ -1146,7 +1188,7 @@ export const EntityList = forwardRef<
)}
</button>
<PenLine className="w-4 h-4" />
<span>Prompts</span>
<span>Variables</span>
</div>
<Button
variant="secondary"
@ -1154,11 +1196,11 @@ export const EntityList = forwardRef<
onClick={(e) => {
e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, prompts: true }));
onAddPrompt({});
setShowAddVariableModal(true);
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Prompt"
hoverContent="Add Variable"
>
<PlusIcon className="w-4 h-4" />
</Button>
@ -1174,8 +1216,9 @@ export const EntityList = forwardRef<
<ListItemWithMenu
key={`prompt-${index}`}
name={prompt.name}
value={prompt.prompt}
isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name}
onClick={() => onSelectPrompt(prompt.name)}
onClick={() => handleVariableClick(prompt)}
selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined}
icon={<ScrollText className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />}
menuContent={
@ -1188,7 +1231,7 @@ export const EntityList = forwardRef<
))}
</div>
) : (
<EmptyState entity="prompts" hasFilteredItems={false} />
<EmptyState entity="variables" hasFilteredItems={false} />
)}
</div>
</div>
@ -1227,6 +1270,27 @@ export const EntityList = forwardRef<
useRagS3Uploads={useRagS3Uploads}
useRagScraping={useRagScraping}
/>
<AddVariableModal
isOpen={showAddVariableModal}
onClose={() => {
setShowAddVariableModal(false);
setEditingVariable(null);
}}
onConfirm={(name, value) => {
if (editingVariable) {
// Update existing variable using modal-specific handler
onUpdatePromptFromModal(editingVariable.name, { name, prompt: value });
} else {
// Add new variable using modal-specific handler
onAddPromptFromModal({ name, prompt: value });
}
setShowAddVariableModal(false);
setEditingVariable(null);
}}
initialName={editingVariable?.name}
initialValue={editingVariable?.value}
isEditing={!!editingVariable}
/>
</div>
);
});
@ -1922,4 +1986,134 @@ function AgentTypeModal({ isOpen, onClose, onConfirm, onCreatePipeline }: AgentT
</ModalContent>
</Modal>
);
}
interface AddVariableModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (name: string, value: string) => void;
initialName?: string;
initialValue?: string;
isEditing?: boolean;
}
function AddVariableModal({ isOpen, onClose, onConfirm, initialName, initialValue, isEditing = false }: AddVariableModalProps) {
const [name, setName] = useState('');
const [value, setValue] = useState('');
const [errors, setErrors] = useState<{ name?: string; value?: string }>({});
// Initialize form with values when modal opens
useEffect(() => {
if (isOpen) {
setName(initialName || '');
setValue(initialValue || '');
setErrors({});
}
}, [isOpen, initialName, initialValue]);
const resetForm = () => {
setName('');
setValue('');
setErrors({});
};
const handleClose = () => {
resetForm();
onClose();
};
const handleConfirm = () => {
const newErrors: { name?: string; value?: string } = {};
if (!name.trim()) {
newErrors.name = 'Variable name is required';
}
if (!value.trim()) {
newErrors.value = 'Variable value is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onConfirm(name.trim(), value.trim());
resetForm();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<ModalContent>
<ModalHeader>
<div className="flex items-center gap-2">
<PenLine className="w-5 h-5 text-indigo-600" />
<span>{isEditing ? 'Edit Variable' : 'Add Variable'}</span>
</div>
</ModalHeader>
<ModalBody className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Variable Name
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors(prev => ({ ...prev, name: undefined }));
}}
placeholder="Enter variable name (e.g., greeting_message)"
className={clsx(
"w-full px-3 py-2 border rounded-md text-sm",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500",
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100",
errors.name ? "border-red-500" : "border-gray-300 dark:border-gray-600"
)}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Variable Value
</label>
<textarea
value={value}
onChange={(e) => {
setValue(e.target.value);
if (errors.value) setErrors(prev => ({ ...prev, value: undefined }));
}}
placeholder="Enter the variable value..."
rows={4}
className={clsx(
"w-full px-3 py-2 border rounded-md text-sm resize-none",
"focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500",
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100",
errors.value ? "border-red-500" : "border-gray-300 dark:border-gray-600"
)}
/>
{errors.value && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.value}</p>
)}
</div>
</ModalBody>
<ModalFooter>
<Button
variant="secondary"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleConfirm}
>
{isEditing ? 'Update Variable' : 'Add Variable'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -32,7 +32,7 @@ export default async function Page(
const project = await fetchProjectController.execute({
caller: "user",
userId: user._id,
userId: user.id,
projectId: params.projectId,
});
if (!project) {
@ -41,13 +41,13 @@ export default async function Page(
const sources = await listDataSourcesController.execute({
caller: "user",
userId: user._id,
userId: user.id,
projectId: params.projectId,
});
let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*';
if (USE_BILLING) {
eligibleModels = await getEligibleModels(customer._id);
eligibleModels = await getEligibleModels(customer.id);
}
console.log('/workflow page.tsx serve');

View file

@ -85,6 +85,9 @@ export type Action = {
} | {
type: "add_prompt";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "add_prompt_no_select";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "add_pipeline";
pipeline: Partial<z.infer<typeof WorkflowPipeline>>;
@ -143,6 +146,10 @@ export type Action = {
type: "update_prompt";
name: string;
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "update_prompt_no_select";
name: string;
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "toggle_agent";
name: string;
@ -391,10 +398,10 @@ function reducer(state: State, action: Action): State {
if (isLive) {
break;
}
let newPromptName = "New prompt";
let newPromptName = "New Variable";
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
newPromptName = `New prompt ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New prompt")).length + 1}`;
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New Variable")).length + 1}`;
}
draft.workflow?.prompts.push({
name: newPromptName,
@ -410,6 +417,26 @@ function reducer(state: State, action: Action): State {
draft.chatKey++;
break;
}
case "add_prompt_no_select": {
if (isLive) {
break;
}
let newPromptName = "New Variable";
if (draft.workflow?.prompts.some((prompt) => prompt.name === newPromptName)) {
newPromptName = `New Variable ${draft.workflow?.prompts.filter((prompt) =>
prompt.name.startsWith("New Variable")).length + 1}`;
}
draft.workflow?.prompts.push({
name: newPromptName,
type: "base_prompt",
prompt: "",
...action.prompt
});
// Don't set selection - this is the key difference
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "add_pipeline": {
if (isLive) {
break;
@ -751,6 +778,47 @@ function reducer(state: State, action: Action): State {
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_prompt_no_select":
if (isLive) {
break;
}
// update prompt data
draft.workflow.prompts = draft.workflow.prompts.map((prompt) =>
prompt.name === action.name ? { ...prompt, ...action.prompt } : prompt
);
// if the prompt is renamed
if (action.prompt.name && action.prompt.name !== action.name) {
// update this prompts references in other agents / prompts
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
instructions: agent.instructions.replace(
`[@prompt:${action.name}](#mention)`,
`[@prompt:${action.prompt.name}](#mention)`
)
}));
draft.workflow.prompts = draft.workflow.prompts.map(prompt => ({
...prompt,
prompt: prompt.prompt.replace(
`[@prompt:${action.name}](#mention)`,
`[@prompt:${action.prompt.name}](#mention)`
)
}));
// if this is the selected prompt, update the selection
if (draft.selection?.type === "prompt" && draft.selection.name === action.name) {
draft.selection = {
type: "prompt",
name: action.prompt.name
};
}
}
// Don't set selection - this is the key difference
draft.pendingChanges = true;
draft.chatKey++;
break;
case "toggle_agent":
if (isLive) {
break;
@ -1074,6 +1142,15 @@ export function WorkflowEditor({
dispatch({ type: "update_prompt", name, prompt });
}
// Modal-specific handlers that don't auto-select
function handleAddPromptFromModal(prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
dispatch({ type: "add_prompt_no_select", prompt });
}
function handleUpdatePromptFromModal(name: string, prompt: Partial<z.infer<typeof WorkflowPrompt>>) {
dispatch({ type: "update_prompt_no_select", name, prompt });
}
function handleDeletePrompt(name: string) {
if (window.confirm(`Are you sure you want to delete the prompt "${name}"?`)) {
dispatch({ type: "delete_prompt", name });
@ -1129,7 +1206,23 @@ export function WorkflowEditor({
// Remove handleCopyJSON and add handleDownloadJSON
function handleDownloadJSON() {
const workflow = state.present.workflow;
const json = JSON.stringify(workflow, null, 2);
// Create a copy of the workflow and replace variable values with dummy text
const workflowCopy = {
...workflow,
prompts: workflow.prompts.map(prompt => {
// If this is a variable (base_prompt type), replace its value with dummy text
if (prompt.type === 'base_prompt') {
return {
...prompt,
prompt: '<needs to be added>'
};
}
return prompt;
})
};
const json = JSON.stringify(workflowCopy, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@ -1310,6 +1403,9 @@ export function WorkflowEditor({
onAddAgent={handleAddAgent}
onAddTool={handleAddTool}
onAddPrompt={handleAddPrompt}
onUpdatePrompt={handleUpdatePrompt}
onAddPromptFromModal={handleAddPromptFromModal}
onUpdatePromptFromModal={handleUpdatePromptFromModal}
onAddPipeline={handleAddPipeline}
onAddAgentToPipeline={handleAddAgentToPipeline}
onToggleAgent={handleToggleAgent}

View file

@ -256,24 +256,27 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
{/* Theme and Auth Controls */}
<div className="p-3 border-t border-zinc-100 dark:border-zinc-800 space-y-2">
{USE_PRODUCT_TOUR && !isProjectsRoute && (
<Tooltip content={collapsed ? "Help" : ""} showArrow placement="right">
<button
onClick={showHelpModal}
className={`
w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
hover:bg-zinc-100 dark:hover:bg-zinc-800/50
text-zinc-600 dark:text-zinc-400
`}
data-tour-target="tour-button"
>
<HelpCircle size={COLLAPSED_ICON_SIZE} />
{!collapsed && <span>Help</span>}
</button>
</Tooltip>
)}
{/* Help button - always visible, but behavior depends on feature flag */}
<Tooltip content={collapsed ? "Help" : ""} showArrow placement="right">
<button
onClick={USE_PRODUCT_TOUR ? showHelpModal : () => {
// Basic help behavior when tour is disabled
// You can customize this to show a different help modal or redirect
window.open('https://discord.com/invite/rxB8pzHxaS', '_blank');
}}
className={`
w-full rounded-md flex items-center
text-[15px] font-medium transition-all duration-200
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
hover:bg-zinc-100 dark:hover:bg-zinc-800/50
text-zinc-600 dark:text-zinc-400
`}
data-tour-target="tour-button"
>
<HelpCircle size={COLLAPSED_ICON_SIZE} />
{!collapsed && <span>Help</span>}
</button>
</Tooltip>
{SHOW_DARK_MODE_TOGGLE && (
<Tooltip content={collapsed ? "Appearance" : ""} showArrow placement="right">

View file

@ -433,7 +433,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: z.infer<typeof Da
logger.log("Error processing doc:", e);
await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, {
status: "error",
error: e.message,
error: "Error processing doc",
});
} finally {
// log usage in billing
@ -466,7 +466,7 @@ async function runDeletionPipeline(_logger: PrefixLogger, job: z.infer<typeof Da
logger.log("Error deleting doc:", e);
await dataSourceDocsRepository.updateByVersion(doc.id, doc.version, {
status: "error",
error: e.message,
error: "Error deleting doc",
});
}
}

View file

@ -81,56 +81,6 @@ export function Panel({
onClick={onClick}
data-tour-target={tourTarget}
>
{variant === 'copilot' && showWelcome && (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -mt-8">
{/* Replace Sparkles icon with mascot image */}
<Image src={mascot} alt="Rowboat Mascot" width={192} height={192} className="object-contain mb-4 animate-float" />
{/* Welcome/Intro Section */}
<div className="text-center max-w-md px-6 mb-4">
<h3 className="text-xl font-semibold text-zinc-700 dark:text-zinc-300 mb-3 text-center">
👋 Welcome to Rowboat!
</h3>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-6 text-center">
I&apos;m your copilot for building agents and adding tools to them.
</p>
<p className="text-base text-zinc-600 dark:text-zinc-400 mb-4 text-center">
Here&apos;s what you can do in Rowboat:
</p>
<div className="space-y-3 max-w-2xl mx-auto text-left">
<div className="flex items-start gap-3">
<span className="text-lg"></span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Build AI agents instantly with natural language.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔌</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Connect tools with one-click integrations.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">📂</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Power with knowledge by adding documents for RAG.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🔄</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Automate workflows by setting up triggers and actions.</span>
</div>
<div className="flex items-start gap-3">
<span className="text-lg">🚀</span>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Deploy anywhere via API or SDK.</span>
</div>
</div>
</div>
{SHOW_COPILOT_MARQUEE && (
<div className="relative mt-4 max-w-full px-8">
<div className="font-mono text-sm whitespace-nowrap text-blue-400/60 dark:text-blue-500/40 font-small inline-flex">
<div className="overflow-hidden w-0 animate-typing">What can I help you build?</div>
<div className="border-r-2 border-blue-400 dark:border-blue-500 animate-cursor">&nbsp;</div>
</div>
</div>
)}
</div>
)}
<div
className={clsx(
"shrink-0 border-b relative",
@ -207,7 +157,9 @@ export function Panel({
<div className="px-4 py-3">
{children}
</div>
) : children}
) : (
children
)}
</div>
</div>;
}

View file

@ -145,6 +145,9 @@ import { UpdateLiveWorkflowController } from "@/src/interface-adapters/controlle
import { RevertToLiveWorkflowUseCase } from "@/src/application/use-cases/projects/revert-to-live-workflow.use-case";
import { RevertToLiveWorkflowController } from "@/src/interface-adapters/controllers/projects/revert-to-live-workflow.controller";
// users
import { MongoDBUsersRepository } from "@/src/infrastructure/repositories/mongodb.users.repository";
export const container = createContainer({
injectionMode: InjectionMode.PROXY,
strict: true,
@ -324,4 +327,8 @@ container.register({
runTurnController: asClass(RunTurnController).singleton(),
listConversationsController: asClass(ListConversationsController).singleton(),
fetchConversationController: asClass(FetchConversationController).singleton(),
// users
// ---
usersRepository: asClass(MongoDBUsersRepository).singleton(),
});

View file

@ -412,7 +412,7 @@ export function createMockTool(
} catch (error) {
logger.log(`Error executing mock tool ${config.name}:`, error);
return JSON.stringify({
error: `Mock tool execution failed: ${error}`,
error: "Tool execution failed!",
});
}
}
@ -447,7 +447,7 @@ export function createWebhookTool(
} catch (error) {
logger.log(`Error executing webhook tool ${config.name}:`, error);
return JSON.stringify({
error: `Tool execution failed: ${error}`,
error: "Tool execution failed!",
});
}
}
@ -482,7 +482,7 @@ export function createMcpTool(
} catch (error) {
logger.log(`Error executing mcp tool ${name}:`, error);
return JSON.stringify({
error: `Tool execution failed: ${error}`,
error: "Tool execution failed!",
});
}
}
@ -521,7 +521,7 @@ export function createComposioTool(
} catch (error) {
logger.log(`Error executing composio tool ${name}:`, error);
return JSON.stringify({
error: `Tool execution failed: ${error}`,
error: "Tool execution failed!",
});
}
}

View file

@ -143,4 +143,24 @@ export const PIPELINE_TYPE_INSTRUCTIONS = (): string => `
- Your response should be self-contained and ready to be consumed by the next pipeline step.
- Reading the message history will show you the pipeline execution flow up to your step.
- These are high level instructions only. The user will provide more specific instructions which will be below.
`;
`;
/**
* Instructions for providing variable context to agents
* Appends variable names and values to agent system prompts
*/
export const VARIABLES_CONTEXT_INSTRUCTIONS = (variablesList: Array<{name: string, value: string}>): string => {
if (!variablesList || variablesList.length === 0) {
return '';
}
const variablesText = variablesList
.map(variable => `${variable.name}: ${variable.value}`)
.join('\n');
return `
# Variables Context
Here is information that is already provided:
${variablesText}
`;
};

View file

@ -9,7 +9,7 @@ import crypto from "crypto";
// Internal dependencies
import { createTools, createRagTool } from "./agent-tools";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPipeline, WorkflowPrompt, WorkflowTool } from "@/app/lib/types/workflow_types";
import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, PIPELINE_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS } from "./agent_instructions";
import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, PIPELINE_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS, VARIABLES_CONTEXT_INSTRUCTIONS } from "./agent_instructions";
import { PrefixLogger } from "@/app/lib/utils";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "@/app/lib/types/types";
import { UsageTracker } from "@/app/lib/billing";
@ -99,6 +99,14 @@ function createAgent(
): { agent: Agent, entities: z.infer<typeof ConnectedEntity>[] } {
const agentLogger = logger.child(`createAgent: ${config.name}`);
// Extract variables from workflow prompts (variables are stored as prompts with type 'base_prompt')
const variables = workflow.prompts
.filter(prompt => prompt.type === 'base_prompt')
.map(prompt => ({
name: prompt.name,
value: prompt.prompt
}));
// Combine instructions and examples
let instructions = `${RECOMMENDED_PROMPT_PREFIX}
@ -122,6 +130,8 @@ ${config.instructions}
${config.examples ? ('# Examples\n' + config.examples) : ''}
${VARIABLES_CONTEXT_INSTRUCTIONS(variables)}
${'-'.repeat(100)}
${CHILD_TRANSFER_RELATED_INSTRUCTIONS}
@ -737,29 +747,44 @@ function maybeInjectGiveUpControlInstructions(
async function* handleRawModelStreamEvent(
event: RunRawModelStreamEvent,
agentConfig: Record<string, z.infer<typeof WorkflowAgent>>,
pipelineConfig: Record<string, z.infer<typeof WorkflowPipeline>>,
agentName: string,
turnMsgs: z.infer<typeof Message>[],
usageTracker: UsageTracker,
eventLogger: PrefixLogger,
getAgentState?: (agentName: string) => AgentState
): AsyncIterable<z.infer<typeof ZOutMessage>> {
// check response visibility - could be an agent or pipeline
const agentConfigObj = agentConfig[agentName];
const pipelineConfigObj = pipelineConfig[agentName];
const isInternal = agentConfigObj?.outputVisibility === 'internal' || agentConfigObj?.type === 'pipeline' || !!pipelineConfigObj;
if (event.data.type === 'response_done') {
// Count tool calls (excluding transfer_to_* calls)
const toolCallCount = event.data.response.output.filter(
(output: any) => output.type === 'function_call' && !output.name.startsWith('transfer_to')
).length;
// If we have tool calls, increment pending counter
if (toolCallCount > 0 && getAgentState) {
const state = getAgentState(agentName);
state.pendingToolCalls += toolCallCount;
eventLogger.log(`🔧 Agent ${agentName} has ${toolCallCount} new tool calls (total: ${state.pendingToolCalls})`);
}
for (const output of event.data.response.output) {
if (output.type === 'message') {
for (const c of output.content) {
if (c.type === 'output_text' && c.text.trim()) {
const m: z.infer<typeof Message> = {
role: 'assistant',
content: c.text,
agentName: agentName,
responseType: isInternal ? 'internal' : 'external',
};
turnMsgs.push(m);
yield* emitEvent(eventLogger, m);
}
}
}
// handle tool call invocation
// except for transfer_to_* tool calls
if (output.type === 'function_call' && !output.name.startsWith('transfer_to')) {
if (getAgentState) {
const state = getAgentState(agentName);
state.pendingToolCalls++;
eventLogger.log(`🔧 Agent ${agentName} has ${state.pendingToolCalls} pending tool calls`);
}
const m: z.infer<typeof Message> = {
role: 'assistant',
content: null,
@ -1020,6 +1045,7 @@ async function* handleMessageOutput(
const pipelineConfigObj = pipelineConfig[agentName];
const isInternal = agentConfigObj?.outputVisibility === 'internal' || agentConfigObj?.type === 'pipeline' || !!pipelineConfigObj;
/* ignore handling text messages here in favor of handling raw events
for (const content of event.item.rawItem.content) {
if (content.type === 'output_text') {
// todo: look into what is causing empty messages
@ -1044,6 +1070,7 @@ async function* handleMessageOutput(
yield* emitEvent(eventLogger, msg);
}
}
*/
// if this is an internal agent or pipeline agent, switch to previous agent
if (isInternal) {
@ -1362,10 +1389,20 @@ export async function* streamResponse(
// handle streaming events
for await (const event of result) {
const eventLogger = loopLogger.child(event.type);
eventLogger.log(`*** GOT EVENT ***`, JSON.stringify(event));
switch (event.type) {
case 'raw_model_stream_event':
yield* handleRawModelStreamEvent(event, agentConfig, agentName!, turnMsgs, usageTracker, eventLogger, getAgentState);
yield* handleRawModelStreamEvent(
event,
agentConfig,
pipelineConfig,
agentName!,
turnMsgs,
usageTracker,
eventLogger,
getAgentState,
);
break;
case 'run_item_stream_event':

View file

@ -990,4 +990,92 @@ This workflow is now ready. Once you apply the changes, it will automatically ha
---
### Example 7: Setting the start agent
**User Request**
Can you set the start agent to the Meeting Prep Hub?
**Copilot Response**
Yes, I can set the start agent to the Meeting Prep Hub.
\`\`\`copilot_change
// action: edit
// config_type: start_agent
// name: Meeting Prep Hub
{
"change_description": "Set the start agent to the Meeting Prep Hub.",
"config_changes": {},
}
\`\`\`
---
### Example 8: Delete an agent
**User Request:**
Can you delete the Slack Send Agent?
**Copilot Response:**
Yes, I can delete the Slack Send Agent.
\`\`\`copilot_change
// action: delete
// config_type: agent
// name: Slack Send Agent
{
"change_description": "Delete the Slack Send Agent.",
"config_changes": {},
}
}
\`\`\`
---
### Example 9: Delete a tool
**User Request:**
Can you delete the Search tool?
**Copilot Response:**
Yes, I can delete the Search tool.
\`\`\`copilot_change
// action: delete
// config_type: tool
// name: Search
{
"change_description": "Delete the Search tool.",
"config_changes": {},
}
\`\`\`
---
### Example 10: Delete a pipeline
**User Request:**
Can you delete the Meeting Prep Pipeline?
**Copilot Response:**
Yes, I can delete the Meeting Prep Pipeline.
\`\`\`copilot_change
// action: delete
// config_type: pipeline
// name: Meeting Prep Pipeline
{
"change_description": "Delete the Meeting Prep Pipeline.",
"config_changes": {},
}
\`\`\`
---
`;

View file

@ -21,8 +21,8 @@ export const CopilotAssistantMessageTextPart = z.object({
export const CopilotAssistantMessageActionPart = z.object({
type: z.literal("action"),
content: z.object({
config_type: z.union([z.literal('tool'), z.literal('agent'), z.literal('prompt'), z.literal('pipeline')]),
action: z.union([z.literal('create_new'), z.literal('edit')]),
config_type: z.enum(['tool', 'agent', 'prompt', 'pipeline', 'start_agent']),
action: z.enum(['create_new', 'edit', 'delete']),
name: z.string(),
change_description: z.string(),
config_changes: z.record(z.string(), z.unknown()),

View file

@ -0,0 +1,12 @@
// returns the number of seconds until the next minute
export function secondsToNextMinute(): number {
const now = new Date();
const secondsUntilNextMinute = 60 - now.getSeconds();
return secondsUntilNextMinute;
}
export function minutesToNextHour(): number {
const now = new Date();
const minutesUntilNextHour = 60 - now.getMinutes();
return minutesUntilNextHour;
}

View file

@ -1,4 +1,21 @@
import { QuotaExceededError } from "@/src/entities/errors/common";
export interface IUsageQuotaPolicy {
// this method will throw a QuotaExceededError if the quota is exceeded
assertAndConsume(projectId: string): Promise<void>;
/**
* Asserts that the project has not exceeded its usage quota and consumes the action.
* Used for general project actions.
*
* @param projectId - The ID of the project to assert and consume.
* @throws QuotaExceededError if the quota is exceeded.
*/
assertAndConsumeProjectAction(projectId: string): Promise<void>;
/**
* Asserts that the project has not exceeded its usage quota for running jobs.
*
* @param projectId - The ID of the project to assert and consume.
* @throws QuotaExceededError if the quota is exceeded.
*/
assertAndConsumeRunJobAction(projectId: string): Promise<void>;
}

View file

@ -0,0 +1,19 @@
import { z } from "zod";
import { User } from "@/src/entities/models/user";
export const CreateSchema = User.pick({
auth0Id: true,
email: true,
});
export interface IUsersRepository {
create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof User>>;
fetch(id: string): Promise<z.infer<typeof User> | null>;
fetchByAuth0Id(auth0Id: string): Promise<z.infer<typeof User> | null>;
updateEmail(id: string, email: string): Promise<z.infer<typeof User>>;
updateBillingCustomerId(id: string, billingCustomerId: string): Promise<z.infer<typeof User>>;
}

View file

@ -59,7 +59,7 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// get trigger type info
const triggerType = await getTriggersType(request.data.triggerTypeSlug);

View file

@ -54,7 +54,7 @@ export class DeleteComposioTriggerDeploymentUseCase implements IDeleteComposioTr
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// ensure deployment belongs to this project
const deployment = await this.composioTriggerDeploymentsRepository.fetch(request.deploymentId);

View file

@ -53,7 +53,7 @@ export class FetchComposioTriggerDeploymentUseCase implements IFetchComposioTrig
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return deployment;
}

View file

@ -51,7 +51,7 @@ export class ListComposioTriggerDeploymentsUseCase implements IListComposioTrigg
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch deployments for project
return await this.composioTriggerDeploymentsRepository.listByProjectId(projectId, request.cursor, limit);

View file

@ -61,7 +61,7 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// create cache entry
const key = nanoid();

View file

@ -61,7 +61,7 @@ export class CreateConversationUseCase implements ICreateConversationUseCase {
}
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// if workflow is not provided, fetch workflow
if (!workflow) {

View file

@ -68,7 +68,7 @@ export class FetchCachedTurnUseCase implements IFetchCachedTurnUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// delete from cache
await this.cacheService.delete(`turn-${data.key}`);

View file

@ -54,7 +54,7 @@ export class FetchConversationUseCase implements IFetchConversationUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the conversation
return conversation;

View file

@ -51,7 +51,7 @@ export class ListConversationsUseCase implements IListConversationsUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch conversations for project
return await this.conversationsRepository.list(projectId, request.cursor, limit);

View file

@ -62,7 +62,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
}
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Check billing auth
let billingCustomerId: string | null = null;
@ -165,6 +165,7 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
}
} finally {
// Log billing usage
console.log('finally logging billing usage');
if (USE_BILLING && billingCustomerId) {
await logUsage(billingCustomerId, {
items: usageTracker.flush(),

View file

@ -55,7 +55,7 @@ export class AddDocsToDataSourceUseCase implements IAddDocsToDataSourceUseCase {
projectId: source.projectId,
});
await this.usageQuotaPolicy.assertAndConsume(source.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(source.projectId);
await this.dataSourceDocsRepository.bulkCreate(source.projectId, sourceId, docs);

View file

@ -44,7 +44,7 @@ export class CreateDataSourceUseCase implements ICreateDataSourceUseCase {
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
let _status = "pending";
// Only set status for non-file data sources

View file

@ -49,7 +49,7 @@ export class DeleteDataSourceUseCase implements IDeleteDataSourceUseCase {
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.dataSourcesRepository.update(request.sourceId, {
status: 'deleted',

View file

@ -54,7 +54,7 @@ export class DeleteDocFromDataSourceUseCase implements IDeleteDocFromDataSourceU
projectId: doc.projectId,
});
await this.usageQuotaPolicy.assertAndConsume(doc.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(doc.projectId);
await this.dataSourceDocsRepository.markAsDeleted(docId);

View file

@ -50,7 +50,7 @@ export class FetchDataSourceUseCase implements IFetchDataSourceUseCase {
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return source;
}

View file

@ -58,7 +58,7 @@ export class GetDownloadUrlForFileUseCase implements IGetDownloadUrlForFileUseCa
projectId: file.projectId,
});
await this.usageQuotaPolicy.assertAndConsume(file.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(file.projectId);
if (file.data.type === 'file_local') {
// use the file id instead of path here

View file

@ -60,7 +60,7 @@ export class GetUploadUrlsForFilesUseCase implements IGetUploadUrlsForFilesUseCa
projectId: source.projectId,
});
await this.usageQuotaPolicy.assertAndConsume(source.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(source.projectId);
const urls: { fileId: string, uploadUrl: string, path: string }[] = [];
for (const file of files) {

View file

@ -44,7 +44,7 @@ export class ListDataSourcesUseCase implements IListDataSourcesUseCase {
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// list all sources for now
const sources = [];

View file

@ -55,7 +55,7 @@ export class ListDocsInDataSourceUseCase implements IListDocsInDataSourceUseCase
projectId: source.projectId,
});
await this.usageQuotaPolicy.assertAndConsume(source.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(source.projectId);
// fetch all docs
const docs = [];

View file

@ -58,7 +58,7 @@ export class RecrawlWebDataSourceUseCase implements IRecrawlWebDataSourceUseCase
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.dataSourceDocsRepository.markSourceDocsPending(request.sourceId);

View file

@ -51,7 +51,7 @@ export class ToggleDataSourceUseCase implements IToggleDataSourceUseCase {
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await this.dataSourcesRepository.update(request.sourceId, { active: request.active });
}

View file

@ -55,7 +55,7 @@ export class UpdateDataSourceUseCase implements IUpdateDataSourceUseCase {
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await this.dataSourcesRepository.update(request.sourceId, request.data, true);
}

View file

@ -54,7 +54,7 @@ export class FetchJobUseCase implements IFetchJobUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the job
return job;

View file

@ -52,7 +52,7 @@ export class ListJobsUseCase implements IListJobsUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch jobs for project
return await this.jobsRepository.list(projectId, request.filters, request.cursor, limit);

View file

@ -48,7 +48,7 @@ export class AddCustomMcpServerUseCase implements IAddCustomMcpServerUseCase {
const { caller, userId, apiKey, projectId, name } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// Validate server URL
const serverUrl = validateHttpHttpsUrl(request.server.serverUrl);

View file

@ -44,7 +44,7 @@ export class CreateComposioManagedConnectedAccountUseCase implements ICreateComp
const { caller, userId, apiKey, projectId, toolkitSlug, callbackUrl } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch managed auth configs
const configs = await listAuthConfigs(toolkitSlug, null, true);

View file

@ -50,7 +50,7 @@ export class CreateCustomConnectedAccountUseCase implements ICreateCustomConnect
const { caller, userId, apiKey, projectId, toolkitSlug, authConfig, callbackUrl } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// create custom auth config
const created: z.infer<typeof ZCreateAuthConfigResponse> = await createAuthConfig({

View file

@ -65,7 +65,7 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
}
// validate enough credits
const result = await authorize(customer._id, {
const result = await authorize(customer.id, {
type: "create_project",
data: {
existingProjectCount: count,
@ -113,7 +113,7 @@ export class CreateProjectUseCase implements ICreateProjectUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(project.id);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(project.id);
return project;
}

View file

@ -56,7 +56,7 @@ export class DeleteComposioConnectedAccountUseCase implements IDeleteComposioCon
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch project
const project = await this.projectsRepository.fetch(projectId);

View file

@ -52,7 +52,7 @@ export class FetchProjectUseCase implements IFetchProjectUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await this.projectsRepository.fetch(projectId);
}

View file

@ -28,7 +28,7 @@ export class GetComposioToolkitUseCase implements IGetComposioToolkitUseCase {
async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<typeof ZGetToolkitResponse>> {
const { caller, userId, apiKey, projectId, toolkitSlug } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await getToolkit(toolkitSlug);
}
}

View file

@ -29,7 +29,7 @@ export class ListComposioToolkitsUseCase implements IListComposioToolkitsUseCase
async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
const { caller, userId, apiKey, projectId, cursor } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await listToolkits(cursor ?? null);
}
}

View file

@ -31,7 +31,7 @@ export class ListComposioToolsUseCase implements IListComposioToolsUseCase {
async execute(request: z.infer<typeof InputSchema>): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
const { caller, userId, apiKey, projectId, toolkitSlug, searchQuery, cursor } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
return await listTools(toolkitSlug, searchQuery ?? null, cursor ?? null);
}
}

View file

@ -37,7 +37,7 @@ export class RemoveCustomMcpServerUseCase implements IRemoveCustomMcpServerUseCa
async execute(request: z.infer<typeof InputSchema>): Promise<void> {
const { caller, userId, apiKey, projectId, name } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.projectsRepository.deleteCustomMcpServer(projectId, name);
}
}

View file

@ -42,7 +42,7 @@ export class RevertToLiveWorkflowUseCase implements IRevertToLiveWorkflowUseCase
apiKey: request.apiKey,
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const project = await this.projectsRepository.fetch(projectId);
if (!project) {

View file

@ -37,7 +37,7 @@ export class RotateSecretUseCase implements IRotateSecretUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const secret = crypto.randomBytes(32).toString("hex");
await this.projectsRepository.updateSecret(projectId, secret);

View file

@ -42,7 +42,7 @@ export class SyncConnectedAccountUseCase implements ISyncConnectedAccountUseCase
const { caller, userId, apiKey, projectId, toolkitSlug, connectedAccountId } = request;
await this.projectActionAuthorizationPolicy.authorize({ caller, userId, apiKey, projectId });
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch project & account to verify
const project = await this.projectsRepository.fetch(projectId);

View file

@ -43,7 +43,7 @@ export class UpdateDraftWorkflowUseCase implements IUpdateDraftWorkflowUseCase {
apiKey: request.apiKey,
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const workflow = { ...request.workflow, lastUpdatedAt: new Date().toISOString() } as z.infer<typeof Workflow>;
await this.projectsRepository.updateDraftWorkflow(projectId, workflow);

View file

@ -43,7 +43,7 @@ export class UpdateLiveWorkflowUseCase implements IUpdateLiveWorkflowUseCase {
apiKey: request.apiKey,
projectId,
});
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
const workflow = { ...request.workflow, lastUpdatedAt: new Date().toISOString() } as z.infer<typeof Workflow>;
await this.projectsRepository.updateLiveWorkflow(projectId, workflow);

View file

@ -36,7 +36,7 @@ export class UpdateProjectNameUseCase implements IUpdateProjectNameUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.projectsRepository.updateName(projectId, name);
}

View file

@ -36,7 +36,7 @@ export class UpdateWebhookUrlUseCase implements IUpdateWebhookUrlUseCase {
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
await this.projectsRepository.updateWebhookUrl(projectId, url);
}

View file

@ -55,7 +55,7 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// create the recurring job rule
const rule = await this.recurringJobRulesRepository.create({

View file

@ -45,7 +45,7 @@ export class DeleteRecurringJobRuleUseCase implements IDeleteRecurringJobRuleUse
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// ensure rule belongs to this project
const rule = await this.recurringJobRulesRepository.fetch(request.ruleId);

View file

@ -54,7 +54,7 @@ export class FetchRecurringJobRuleUseCase implements IFetchRecurringJobRuleUseCa
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the rule
return rule;

View file

@ -49,7 +49,7 @@ export class ListRecurringJobRulesUseCase implements IListRecurringJobRulesUseCa
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch recurring job rules for project
return await this.recurringJobRulesRepository.list(projectId, request.cursor, limit);

View file

@ -55,7 +55,7 @@ export class ToggleRecurringJobRuleUseCase implements IToggleRecurringJobRuleUse
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// update the rule
return await this.recurringJobRulesRepository.toggle(request.ruleId, request.disabled);

View file

@ -50,7 +50,7 @@ export class CreateScheduledJobRuleUseCase implements ICreateScheduledJobRuleUse
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// create the scheduled job rule with UTC time
const rule = await this.scheduledJobRulesRepository.create({

View file

@ -45,7 +45,7 @@ export class DeleteScheduledJobRuleUseCase implements IDeleteScheduledJobRuleUse
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(request.projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
// ensure rule belongs to this project
const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId);

View file

@ -54,7 +54,7 @@ export class FetchScheduledJobRuleUseCase implements IFetchScheduledJobRuleUseCa
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// return the scheduled job rule
return rule;

View file

@ -49,7 +49,7 @@ export class ListScheduledJobRulesUseCase implements IListScheduledJobRulesUseCa
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
await this.usageQuotaPolicy.assertAndConsumeProjectAction(projectId);
// fetch scheduled job rules for project
return await this.scheduledJobRulesRepository.list(projectId, request.cursor, limit);

View file

@ -8,6 +8,7 @@ import { z } from "zod";
import { nanoid } from "nanoid";
import { PrefixLogger } from "@/app/lib/utils";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { secondsToNextMinute } from "../lib/utils/time-to-next-minute";
export interface IJobRulesWorker {
run(): Promise<void>;
@ -20,8 +21,6 @@ export class JobRulesWorker implements IJobRulesWorker {
private readonly jobsRepository: IJobsRepository;
private readonly projectsRepository: IProjectsRepository;
private readonly pubSubService: IPubSubService;
// Run polls aligned to minute marks at this offset (e.g., 2000 ms => :02 each minute)
private readonly minuteAlignmentOffsetMs: number = 2_000;
private workerId: string;
private logger: PrefixLogger;
private isRunning: boolean = false;
@ -131,14 +130,6 @@ export class JobRulesWorker implements IJobRulesWorker {
}
}
// Calculates delay so the next run happens at next minute + minuteAlignmentOffsetMs
private calculateDelayToNextAlignedMinute(): number {
const now = new Date();
const millisecondsUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
const delayMs = millisecondsUntilNextMinute + this.minuteAlignmentOffsetMs;
return delayMs > 0 ? delayMs : this.minuteAlignmentOffsetMs;
}
private async pollScheduled(): Promise<void> {
const logger = this.logger.child(`poll-scheduled`);
logger.log("Polling...");
@ -176,7 +167,8 @@ export class JobRulesWorker implements IJobRulesWorker {
}
private scheduleNextPoll(): void {
const delayMs = this.calculateDelayToNextAlignedMinute();
// schedule next poll for 2 s past the minute mark
const delayMs = (secondsToNextMinute() + 2) * 1000;
this.logger.log(`Scheduling next poll in ${delayMs} ms`);
this.pollTimeoutId = setTimeout(async () => {
if (!this.isRunning) return;
@ -195,7 +187,6 @@ export class JobRulesWorker implements IJobRulesWorker {
}
this.isRunning = true;
this.logger.log(`Starting worker ${this.workerId}`);
// No immediate polling; align to 2s past the next minute
this.scheduleNextPoll();
}

Some files were not shown because too many files have changed in this diff Show more