simplify workflow version mgmt

This commit is contained in:
Ramnique Singh 2025-07-18 01:18:47 +05:30
parent 1b19a9bcba
commit 23681d8b4d
49 changed files with 385 additions and 4767 deletions

View file

@ -58,12 +58,13 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
}
export async function getAssistantResponseStreamId(
projectId: string,
workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[],
): Promise<{ streamId: string } | { billingError: string }> {
await projectAuthCheck(workflow.projectId);
if (!await check_query_limit(workflow.projectId)) {
await projectAuthCheck(projectId);
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
@ -82,6 +83,6 @@ export async function getAssistantResponseStreamId(
return { billingError: error || 'Billing error' };
}
const response = await getAgenticResponseStreamId(workflow, projectTools, messages);
const response = await getAgenticResponseStreamId(projectId, workflow, projectTools, messages);
return response;
}

View file

@ -2,7 +2,7 @@
import { z } from "zod";
import { WorkflowTool } from "../lib/types/workflow_types";
import { projectAuthCheck } from "./project_actions";
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
import { projectsCollection } from "../lib/mongodb";
import { Project } from "../lib/types/project_types";
import { MCPServer, McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
import { getMcpClient } from "../lib/mcp";
@ -169,72 +169,6 @@ export async function updateMcpServers(projectId: string, mcpServers: z.infer<ty
}, { $set: { mcpServers } });
}
export async function listMcpServers(projectId: string): Promise<z.infer<typeof MCPServer>[]> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
return project?.mcpServers ?? [];
}
export async function updateToolInAllWorkflows(
projectId: string,
mcpServer: z.infer<typeof MCPServer>,
toolId: string,
shouldAdd: boolean
): Promise<void> {
await projectAuthCheck(projectId);
// 1. Get all workflows in the project
const workflows = await agentWorkflowsCollection.find({ projectId }).toArray();
// 2. For each workflow
for (const workflow of workflows) {
// 3. Find if the tool already exists in this workflow
const existingTool = workflow.tools.find(t =>
t.isMcp &&
t.mcpServerName === mcpServer.name &&
t.name === toolId
);
if (shouldAdd && !existingTool) {
// 4a. If adding and tool doesn't exist, add it
const tool = mcpServer.tools.find(t => t.id === toolId);
if (tool) {
const workflowTool = convertMcpServerToolToWorkflowTool(
{
name: tool.name,
description: tool.description,
inputSchema: {
type: 'object',
properties: tool.parameters?.properties ?? {},
required: tool.parameters?.required ?? [],
},
},
mcpServer
);
workflow.tools.push(workflowTool);
}
} else if (!shouldAdd && existingTool) {
// 4b. If removing and tool exists, remove it
workflow.tools = workflow.tools.filter(t =>
!(t.isMcp && t.mcpServerName === mcpServer.name && t.name === toolId)
);
}
// 5. Update the workflow
await agentWorkflowsCollection.updateOne(
{ _id: workflow._id },
{
$set: {
tools: workflow.tools,
lastUpdatedAt: new Date().toISOString()
}
}
);
}
}
export async function toggleMcpTool(
projectId: string,
serverName: string,

View file

@ -1,7 +1,7 @@
'use server';
import { redirect } from "next/navigation";
import { ObjectId } from "mongodb";
import { dataSourcesCollection, embeddingsCollection, projectsCollection, agentWorkflowsCollection, testScenariosCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection, testProfilesCollection } from "../lib/mongodb";
import { db, dataSourcesCollection, embeddingsCollection, projectsCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection } from "../lib/mongodb";
import { z } from 'zod';
import crypto from 'crypto';
import { revalidatePath } from "next/cache";
@ -33,7 +33,11 @@ export async function projectAuthCheck(projectId: string) {
}
}
async function createBaseProject(name: string, user: WithStringId<z.infer<typeof User>>): Promise<{ id: string } | { billingError: string }> {
async function createBaseProject(
name: string,
user: WithStringId<z.infer<typeof User>>,
workflow?: z.infer<typeof Workflow>
): Promise<{ id: string } | { billingError: string }> {
// fetch project count for this user
const projectCount = await projectsCollection.countDocuments({
createdByUserId: user._id,
@ -60,9 +64,10 @@ async function createBaseProject(name: string, user: WithStringId<z.infer<typeof
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
createdByUserId: user._id,
draftWorkflow: workflow,
liveWorkflow: workflow,
chatClientId,
secret,
nextWorkflowNumber: 1,
testRunCounter: 0,
});
@ -85,26 +90,19 @@ export async function createProject(formData: FormData): Promise<{ id: string }
const name = formData.get('name') as string;
const templateKey = formData.get('template') as string;
const response = await createBaseProject(name, user);
const { agents, prompts, tools, startAgent } = templates[templateKey];
const response = await createBaseProject(name, user, {
agents,
prompts,
tools,
startAgent,
lastUpdatedAt: (new Date()).toISOString(),
});
if ('billingError' in response) {
return response;
}
const projectId = response.id;
// Add first workflow version with specified template
const { agents, prompts, tools, startAgent } = templates[templateKey];
await agentWorkflowsCollection.insertOne({
projectId,
agents,
prompts,
tools,
startAgent,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
name: `Version 1`,
});
return { id: projectId };
}
@ -270,13 +268,13 @@ export async function deleteProject(projectId: string) {
projectId,
});
// delete workflows
await agentWorkflowsCollection.deleteMany({
// delete workflow versions
await db.collection('agent_workflows').deleteMany({
projectId,
});
// delete scenarios
await testScenariosCollection.deleteMany({
await db.collection('test_scenarios').deleteMany({
projectId,
});
@ -292,26 +290,19 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id:
const user = await authCheck();
const name = formData.get('name') as string;
const response = await createBaseProject(name, user);
const { agents, prompts, tools, startAgent } = templates['default'];
const response = await createBaseProject(name, user, {
agents,
prompts,
tools,
startAgent,
lastUpdatedAt: (new Date()).toISOString(),
});
if ('billingError' in response) {
return response;
}
const projectId = response.id;
// Add first workflow version with default template
const { agents, prompts, tools, startAgent } = templates['default'];
await agentWorkflowsCollection.insertOne({
projectId,
agents,
prompts,
tools,
startAgent,
createdAt: (new Date()).toISOString(),
lastUpdatedAt: (new Date()).toISOString(),
name: `Version 1`,
});
return { id: projectId };
}
@ -325,29 +316,69 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
throw new Error('Invalid JSON');
}
// Validate and parse with zod
const parsed = Workflow.omit({ projectId: true }).safeParse(workflowData);
const parsed = Workflow.safeParse(workflowData);
if (!parsed.success) {
throw new Error('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
}
const workflow = parsed.data;
const name = workflow.name || 'Imported Project';
const response = await createBaseProject(name, user);
const name = 'Imported Project';
const response = await createBaseProject(name, user, workflow);
if ('billingError' in response) {
return response;
}
const projectId = response.id;
const now = new Date().toISOString();
await agentWorkflowsCollection.insertOne({
...workflow,
projectId,
createdAt: now,
lastUpdatedAt: now,
name: workflow.name || 'Version 1',
});
return { id: projectId };
}
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
return libCollectProjectTools(projectId);
}
export async function saveWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId);
// update the project's draft workflow
workflow.lastUpdatedAt = new Date().toISOString();
await projectsCollection.updateOne({
_id: projectId,
}, {
$set: {
draftWorkflow: workflow,
},
});
}
export async function publishWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId);
// update the project's draft workflow
workflow.lastUpdatedAt = new Date().toISOString();
await projectsCollection.updateOne({
_id: projectId,
}, {
$set: {
liveWorkflow: workflow,
},
});
}
export async function revertToLiveWorkflow(projectId: string) {
await projectAuthCheck(projectId);
const project = await getProjectConfig(projectId);
const workflow = project.liveWorkflow;
if (!workflow) {
throw new Error('No live workflow found');
}
workflow.lastUpdatedAt = new Date().toISOString();
await projectsCollection.updateOne({
_id: projectId,
}, {
$set: {
draftWorkflow: workflow,
},
});
}

View file

@ -1,610 +0,0 @@
'use server';
import { ObjectId } from "mongodb";
import { testScenariosCollection, testSimulationsCollection, testProfilesCollection, testRunsCollection, testResultsCollection, projectsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { projectAuthCheck } from "./project_actions";
import { type WithStringId } from "../lib/types/types";
import { TestScenario, TestSimulation, TestProfile, TestRun, TestResult } from "../lib/types/testing_types";
export async function listScenarios(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
scenarios: WithStringId<z.infer<typeof TestScenario>>[];
total: number;
}> {
await projectAuthCheck(projectId);
// Calculate skip value for pagination
const skip = (page - 1) * pageSize;
// Get total count for pagination
const total = await testScenariosCollection.countDocuments({ projectId });
// Get paginated scenarios
const scenarios = await testScenariosCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
scenarios: scenarios.map(scenario => ({
...scenario,
_id: scenario._id.toString(),
})),
total,
};
}
export async function getScenario(projectId: string, scenarioId: string): Promise<WithStringId<z.infer<typeof TestScenario>> | null> {
await projectAuthCheck(projectId);
// fetch scenario
const scenario = await testScenariosCollection.findOne({
_id: new ObjectId(scenarioId),
projectId,
});
if (!scenario) {
return null;
}
const { _id, ...rest } = scenario;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteScenario(projectId: string, scenarioId: string): Promise<void> {
await projectAuthCheck(projectId);
await testScenariosCollection.deleteOne({
_id: new ObjectId(scenarioId),
projectId,
});
}
export async function createScenario(
projectId: string,
data: {
name: string;
description: string;
}
): Promise<WithStringId<z.infer<typeof TestScenario>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testScenariosCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateScenario(
projectId: string,
scenarioId: string,
updates: {
name?: string;
description?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testScenariosCollection.updateOne(
{
_id: new ObjectId(scenarioId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listSimulations(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
simulations: WithStringId<z.infer<typeof TestSimulation>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testSimulationsCollection.countDocuments({ projectId });
const simulations = await testSimulationsCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
simulations: simulations.map(simulation => ({
...simulation,
_id: simulation._id.toString(),
})),
total,
};
}
export async function getSimulation(projectId: string, simulationId: string): Promise<WithStringId<z.infer<typeof TestSimulation>> | null> {
await projectAuthCheck(projectId);
const simulation = await testSimulationsCollection.findOne({
_id: new ObjectId(simulationId),
projectId,
});
if (!simulation) {
return null;
}
const { _id, ...rest } = simulation;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteSimulation(projectId: string, simulationId: string): Promise<void> {
await projectAuthCheck(projectId);
await testSimulationsCollection.deleteOne({
_id: new ObjectId(simulationId),
projectId,
});
}
export async function createSimulation(
projectId: string,
data: {
name: string;
description?: string;
scenarioId: string;
profileId: string | null;
passCriteria: string;
}
): Promise<WithStringId<z.infer<typeof TestSimulation>>> {
await projectAuthCheck(projectId);
const doc: z.infer<typeof TestSimulation> = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testSimulationsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateSimulation(
projectId: string,
simulationId: string,
updates: {
name?: string;
description?: string;
scenarioId?: string;
profileId?: string | null;
passCriteria?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testSimulationsCollection.updateOne(
{
_id: new ObjectId(simulationId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listProfiles(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
profiles: WithStringId<z.infer<typeof TestProfile>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testProfilesCollection.countDocuments({ projectId });
const profiles = await testProfilesCollection
.find({ projectId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
profiles: profiles.map(profile => ({
...profile,
_id: profile._id.toString(),
})),
total,
};
}
export async function getProfile(projectId: string, profileId: string): Promise<WithStringId<z.infer<typeof TestProfile>> | null> {
await projectAuthCheck(projectId);
const profile = await testProfilesCollection.findOne({
_id: new ObjectId(profileId),
projectId,
});
if (!profile) {
return null;
}
const { _id, ...rest } = profile;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteProfile(projectId: string, profileId: string): Promise<void> {
await projectAuthCheck(projectId);
await testProfilesCollection.deleteOne({
_id: new ObjectId(profileId),
projectId,
});
}
export async function createProfile(
projectId: string,
data: {
name: string;
context: string;
mockTools: boolean;
mockPrompt?: string;
}
): Promise<WithStringId<z.infer<typeof TestProfile>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const result = await testProfilesCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateProfile(
projectId: string,
profileId: string,
updates: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
lastUpdatedAt: new Date().toISOString(),
};
await testProfilesCollection.updateOne(
{
_id: new ObjectId(profileId),
projectId,
},
{
$set: updateData,
}
);
}
export async function listRuns(
projectId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
runs: WithStringId<z.infer<typeof TestRun>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testRunsCollection.countDocuments({ projectId });
const runs = await testRunsCollection
.find({ projectId })
.sort({ startedAt: -1 }) // Sort by most recent first
.skip(skip)
.limit(pageSize)
.toArray();
return {
runs: runs.map(run => ({
...run,
_id: run._id.toString(),
})),
total,
};
}
export async function getRun(projectId: string, runId: string): Promise<WithStringId<z.infer<typeof TestRun>> | null> {
await projectAuthCheck(projectId);
const run = await testRunsCollection.findOne({
_id: new ObjectId(runId),
projectId,
});
if (!run) {
return null;
}
const { _id, ...rest } = run;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.deleteOne({
_id: new ObjectId(runId),
projectId,
});
}
export async function createRun(
projectId: string,
data: {
simulationIds: string[];
workflowId: string;
}
): Promise<WithStringId<z.infer<typeof TestRun>>> {
await projectAuthCheck(projectId);
// Increment the testRunCounter and get the new value
const result = await projectsCollection.findOneAndUpdate(
{ _id: projectId },
{ $inc: { testRunCounter: 1 } },
{ returnDocument: 'after' }
);
if (!result) {
throw new Error("Project not found");
}
const runNumber = result.testRunCounter || 1;
const doc = {
...data,
projectId,
name: `Run #${runNumber}`,
status: 'pending' as const,
startedAt: new Date().toISOString(),
aggregateResults: {
total: 0,
passCount: 0,
failCount: 0,
},
};
const insertResult = await testRunsCollection.insertOne(doc);
return {
...doc,
_id: insertResult.insertedId.toString(),
};
}
export async function updateRun(
projectId: string,
runId: string,
updates: {
status?: 'pending' | 'running' | 'completed' | 'cancelled' | 'failed' | 'error';
completedAt?: string;
aggregateResults?: {
total: number;
passCount: number;
failCount: number;
};
}
): Promise<void> {
await projectAuthCheck(projectId);
const updateData: any = {
...updates,
};
await testRunsCollection.updateOne(
{
_id: new ObjectId(runId),
projectId,
},
{
$set: updateData,
}
);
}
export async function cancelRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.updateOne(
{ _id: new ObjectId(runId), projectId },
{ $set: { status: 'cancelled' } }
);
}
export async function listResults(
projectId: string,
runId: string,
page: number = 1,
pageSize: number = 10
): Promise<{
results: WithStringId<z.infer<typeof TestResult>>[];
total: number;
}> {
await projectAuthCheck(projectId);
const skip = (page - 1) * pageSize;
const total = await testResultsCollection.countDocuments({ projectId, runId });
const results = await testResultsCollection
.find({ projectId, runId })
.skip(skip)
.limit(pageSize)
.toArray();
return {
results: results.map(result => ({
...result,
_id: result._id.toString(),
})),
total,
};
}
export async function getResult(projectId: string, resultId: string): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
_id: new ObjectId(resultId),
projectId,
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function deleteResult(projectId: string, resultId: string): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.deleteOne({
_id: new ObjectId(resultId),
projectId,
});
}
export async function createResult(
projectId: string,
data: {
runId: string;
simulationId: string;
result: 'pass' | 'fail';
details: string;
transcript: string;
}
): Promise<WithStringId<z.infer<typeof TestResult>>> {
await projectAuthCheck(projectId);
const doc = {
...data,
projectId,
};
const result = await testResultsCollection.insertOne(doc);
return {
...doc,
_id: result.insertedId.toString(),
};
}
export async function updateResult(
projectId: string,
resultId: string,
updates: {
result?: 'pass' | 'fail';
details?: string;
}
): Promise<void> {
await projectAuthCheck(projectId);
await testResultsCollection.updateOne(
{
_id: new ObjectId(resultId),
projectId,
},
{
$set: updates,
}
);
}
export async function getSimulationResult(
projectId: string,
runId: string,
simulationId: string
): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
projectId,
runId,
simulationId
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function listRunSimulations(
projectId: string,
simulationIds: string[]
): Promise<WithStringId<z.infer<typeof TestSimulation>>[]> {
await projectAuthCheck(projectId);
const simulations = await testSimulationsCollection
.find({
_id: { $in: simulationIds.map(id => new ObjectId(id)) },
projectId
})
.toArray();
// Fetch associated scenario and profile names
const enrichedSimulations = await Promise.all(simulations.map(async (simulation) => {
const scenario = simulation.scenarioId ? await testScenariosCollection.findOne({ _id: new ObjectId(simulation.scenarioId) }) : null;
const profile = simulation.profileId ? await testProfilesCollection.findOne({ _id: new ObjectId(simulation.profileId) }) : null;
return {
...simulation,
_id: simulation._id.toString(),
scenarioName: scenario?.name || 'Unknown',
profileName: profile?.name || 'None',
};
}));
return enrichedSimulations;
}

View file

@ -90,13 +90,12 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
found: existingConfig
});
const configToSave = {
const configToSave: z.infer<typeof TwilioConfig> = {
phone_number: params.phone_number,
account_sid: params.account_sid,
auth_token: params.auth_token,
label: params.label || '', // Use empty string instead of undefined
project_id: params.project_id,
workflow_id: params.workflow_id,
createdAt: existingConfig?.createdAt || new Date(),
status: 'active' as const
};
@ -108,7 +107,6 @@ async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Pro
params.phone_number,
params.account_sid,
params.auth_token,
params.workflow_id
);
// Then save/update the config in database
@ -190,7 +188,6 @@ async function configureInboundCall(
phone_number: string,
account_sid: string,
auth_token: string,
workflow_id: string
): Promise<InboundConfigResponse> {
try {
// Normalize phone number format
@ -200,7 +197,6 @@ async function configureInboundCall(
console.log('Configuring inbound call for:', {
phone_number,
workflow_id
});
// Initialize Twilio client
@ -262,7 +258,6 @@ async function configureInboundCall(
return {
status: wasPreviouslyConfigured ? 'reconfigured' : 'configured',
phone_number: phone_number,
workflow_id: workflow_id,
previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined
};

View file

@ -1,241 +0,0 @@
'use server';
import { ObjectId, WithId } from "mongodb";
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
import { z } from 'zod';
import { templates } from "../lib/project_templates";
import { projectAuthCheck } from "./project_actions";
import { WithStringId } from "../lib/types/types";
import { Workflow } from "../lib/types/workflow_types";
export async function createWorkflow(projectId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
// get the next workflow number
const doc = await projectsCollection.findOneAndUpdate({
_id: projectId,
}, {
$inc: {
nextWorkflowNumber: 1,
},
}, {
returnDocument: 'after'
});
if (!doc) {
throw new Error('Project not found');
}
const nextWorkflowNumber = doc.nextWorkflowNumber;
// create the workflow
const { agents, prompts, tools, startAgent } = templates['default'];
const workflow = {
agents,
prompts,
tools,
startAgent,
projectId,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
name: `Version ${nextWorkflowNumber}`,
};
const { insertedId } = await agentWorkflowsCollection.insertOne(workflow);
const { _id, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
return {
...rest,
_id: insertedId.toString(),
};
}
export async function cloneWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
const workflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!workflow) {
throw new Error('Workflow not found');
}
// create a new workflow with the same content
const newWorkflow = {
...workflow,
_id: new ObjectId(),
name: `Copy of ${workflow.name || 'Unnamed workflow'}`,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
const { insertedId } = await agentWorkflowsCollection.insertOne(newWorkflow);
const { _id, ...rest } = newWorkflow as WithId<z.infer<typeof Workflow>>;
return {
...rest,
_id: insertedId.toString(),
};
}
export async function renameWorkflow(projectId: string, workflowId: string, name: string) {
await projectAuthCheck(projectId);
await agentWorkflowsCollection.updateOne({
_id: new ObjectId(workflowId),
projectId,
}, {
$set: {
name,
lastUpdatedAt: new Date().toISOString(),
},
});
}
export async function saveWorkflow(projectId: string, workflowId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId);
// check if workflow exists
const existingWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!existingWorkflow) {
throw new Error('Workflow not found');
}
// ensure that this is not the published workflow for this project
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
if (publishedWorkflowId && publishedWorkflowId === workflowId) {
throw new Error('Cannot save published workflow');
}
// update the workflow, except name and description
const { _id, name, ...rest } = workflow as WithId<z.infer<typeof Workflow>>;
await agentWorkflowsCollection.updateOne({
_id: new ObjectId(workflowId),
}, {
$set: {
...rest,
lastUpdatedAt: new Date().toISOString(),
},
});
}
export async function publishWorkflow(projectId: string, workflowId: string) {
await projectAuthCheck(projectId);
// check if workflow exists
const existingWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!existingWorkflow) {
throw new Error('Workflow not found');
}
// publish the workflow
await projectsCollection.updateOne({
"_id": projectId,
}, {
$set: {
publishedWorkflowId: workflowId,
}
});
}
export async function fetchPublishedWorkflowId(projectId: string): Promise<string | null> {
await projectAuthCheck(projectId);
const project = await projectsCollection.findOne({
_id: projectId,
});
return project?.publishedWorkflowId || null;
}
export async function fetchWorkflow(projectId: string, workflowId: string): Promise<WithStringId<z.infer<typeof Workflow>>> {
await projectAuthCheck(projectId);
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(workflowId),
projectId,
});
if (!workflow) {
throw new Error('Workflow not found');
}
const { _id, ...rest } = workflow;
return {
...rest,
_id: _id.toString(),
};
}
export async function listWorkflows(
projectId: string,
page: number = 1,
limit: number = 10
): Promise<{
workflows: (WithStringId<z.infer<typeof Workflow>>)[];
total: number;
publishedWorkflowId: string | null;
}> {
await projectAuthCheck(projectId);
// fetch total count
const total = await agentWorkflowsCollection.countDocuments({ projectId });
// fetch published workflow
let publishedWorkflowId: string | null = null;
let publishedWorkflow: WithId<z.infer<typeof Workflow>> | null = null;
if (page === 1) {
publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
if (publishedWorkflowId) {
publishedWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(publishedWorkflowId),
projectId,
}, {
projection: {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
lastUpdatedAt: 1,
},
});
}
}
// fetch workflows with pagination
let workflows: WithId<z.infer<typeof Workflow>>[] = await agentWorkflowsCollection.find(
{
projectId,
...(publishedWorkflowId ? {
_id: {
$ne: new ObjectId(publishedWorkflowId)
}
} : {}),
},
{
sort: { lastUpdatedAt: -1 },
projection: {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
lastUpdatedAt: 1,
},
skip: (page - 1) * limit,
limit: limit,
}
).toArray();
workflows = [
...(publishedWorkflow ? [publishedWorkflow] : []),
...workflows,
];
// return workflows
return {
workflows: workflows.map((w) => {
const { _id, ...rest } = w;
return {
...rest,
_id: _id.toString(),
};
}),
total,
publishedWorkflowId,
};
}

View file

@ -1,16 +1,8 @@
import { getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
import { redisClient } from "@/app/lib/redis";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { streamResponse } from "@/app/lib/agents";
import { Message } from "@/app/lib/types/types";
import { z } from "zod";
const PayloadSchema = z.object({
workflow: Workflow,
projectTools: z.array(WorkflowTool),
messages: z.array(Message),
});
import { ZStreamAgentResponsePayload } from "@/app/lib/types/types";
export async function GET(request: Request, props: { params: Promise<{ streamId: string }> }) {
const params = await props.params;
@ -21,13 +13,13 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
}
// parse the payload
const { workflow, projectTools, messages } = PayloadSchema.parse(JSON.parse(payload));
const { projectId, workflow, projectTools, messages } = ZStreamAgentResponsePayload.parse(JSON.parse(payload));
console.log('payload', payload);
// fetch billing customer id
let billingCustomerId: string | null = null;
if (USE_BILLING) {
billingCustomerId = await getCustomerIdForProject(workflow.projectId);
billingCustomerId = await getCustomerIdForProject(projectId);
}
const encoder = new TextEncoder();
@ -37,7 +29,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
async start(controller) {
try {
// Iterate over the generator
for await (const event of streamResponse(workflow, projectTools, messages)) {
for await (const event of streamResponse(projectId, workflow, projectTools, messages)) {
// Check if this is a message event (has role property)
if ('role' in event) {
if (event.role === 'assistant') {

View file

@ -1,9 +1,8 @@
import { getResponse } from "@/app/lib/agents";
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { projectsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { ObjectId } from "mongodb";
import { z } from "zod";
import { TwilioInboundCall } from "@/app/lib/types/voice_types";
import { hangup, reject, XmlResponse, ZStandardRequestParams } from "../utils";
@ -63,16 +62,19 @@ export async function POST(request: Request) {
return reject('rejected');
}
// extract workflow and project id and fetch workflow from db
// fetch project and extract live workflow
// if workflow not found, reject the call
const projectId = twilioConfig.project_id;
const workflowId = twilioConfig.workflow_id;
const workflow = await agentWorkflowsCollection.findOne({
projectId: projectId,
_id: new ObjectId(workflowId),
const project = await projectsCollection.findOne({
_id: projectId,
});
if (!project) {
logger.log(`Project ${projectId} not found`);
return reject('rejected');
}
const workflow = project.liveWorkflow;
if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
logger.log(`Workflow not found for project ${projectId}`);
return reject('rejected');
}
@ -81,7 +83,7 @@ export async function POST(request: Request) {
// this is the first turn, get the initial assistant response
// and validate it
const { messages } = await getResponse(workflow, projectTools, []);
const { messages } = await getResponse(projectId, workflow, projectTools, []);
if (messages.length === 0) {
logger.log('Agent response is empty');
return hangup();
@ -98,7 +100,6 @@ export async function POST(request: Request) {
to: data.To,
from: data.From,
projectId,
workflowId,
messages,
createdAt: recvdAt.toISOString(),
lastUpdatedAt: new Date().toISOString(),

View file

@ -1,9 +1,8 @@
import { getResponse } from "@/app/lib/agents";
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { projectsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { ObjectId } from "mongodb";
import { z } from "zod";
import { hangup, XmlResponse, ZStandardRequestParams } from "../../utils";
import { Message } from "@/app/lib/types/types";
@ -35,15 +34,19 @@ export async function POST(
logger.log('Call not found');
return hangup();
}
const { workflowId, projectId } = call;
const { projectId } = call;
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
projectId: projectId,
_id: new ObjectId(workflowId),
// fetch project and extract live workflow
const project = await projectsCollection.findOne({
_id: projectId,
});
if (!project) {
logger.log(`Project ${projectId} not found`);
return hangup();
}
const workflow = project.liveWorkflow;
if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
logger.log(`Workflow not found for project ${projectId}`);
return hangup();
}
@ -58,7 +61,7 @@ export async function POST(
content: data.SpeechResult,
}
];
const { messages } = await getResponse(workflow, projectTools, reqMessages);
const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages);
if (messages.length === 0) {
logger.log('Agent response is empty');
return hangup();

View file

@ -1,12 +1,11 @@
import { NextRequest } from "next/server";
import { agentWorkflowsCollection, db, projectsCollection, testProfilesCollection } from "../../../../lib/mongodb";
import { projectsCollection } from "../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId } from "mongodb";
import { authCheck } from "../../utils";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { check_query_limit } from "../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../lib/utils";
import { TestProfile } from "@/app/lib/types/testing_types";
import { collectProjectTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags";
@ -65,19 +64,10 @@ export async function POST(
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// if workflow id is provided in the request, use it, else use the published workflow id
let workflowId = result.data.workflowId ?? project.publishedWorkflowId;
if (!workflowId) {
logger.log(`No workflow id provided in request or project has no published workflow`);
return Response.json({ error: "No workflow id provided in request or project has no published workflow" }, { status: 404 });
}
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
projectId: projectId,
_id: new ObjectId(workflowId),
});
const workflow = project.liveWorkflow;
if (!workflow) {
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
logger.log(`Workflow not found for project ${projectId}`);
return Response.json({ error: "Workflow not found" }, { status: 404 });
}
@ -103,21 +93,8 @@ export async function POST(
}
}
// if test profile is provided in the request, use it
let testProfile: z.infer<typeof TestProfile> | null = null;
if (result.data.testProfileId) {
testProfile = await testProfilesCollection.findOne({
projectId: projectId,
_id: new ObjectId(result.data.testProfileId),
});
if (!testProfile) {
logger.log(`Test profile ${result.data.testProfileId} not found for project ${projectId}`);
return Response.json({ error: "Test profile not found" }, { status: 404 });
}
}
// get assistant response
const { messages } = await getResponse(workflow, projectTools, reqMessages);
const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages);
// log billing usage
if (USE_BILLING && billingCustomerId) {

View file

@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiV1 } from "rowboat-shared";
import { agentWorkflowsCollection, projectsCollection, chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
import { projectsCollection, chatsCollection, chatMessagesCollection } from "../../../../../../lib/mongodb";
import { z } from "zod";
import { ObjectId, WithId } from "mongodb";
import { authCheck } from "../../../utils";
@ -185,10 +185,7 @@ export async function POST(
const projectTools = await collectProjectTools(session.projectId);
// fetch workflow
const workflow = await agentWorkflowsCollection.findOne({
projectId: session.projectId,
_id: new ObjectId(projectSettings.publishedWorkflowId),
});
const workflow = projectSettings.liveWorkflow;
if (!workflow) {
throw new Error("Workflow not found");
}
@ -214,7 +211,7 @@ export async function POST(
const inMessages: z.infer<typeof Message>[] = convert(messages);
inMessages.push(userMessage);
const { messages: responseMessages } = await getResponse(workflow, projectTools, [systemMessage, ...inMessages]);
const { messages: responseMessages } = await getResponse(session.projectId, workflow, projectTools, [systemMessage, ...inMessages]);
const convertedResponseMessages = convertBack(responseMessages);
const unsavedMessages = [
userMessage,

View file

@ -489,6 +489,7 @@ function createComposioTool(
// Helper to create an agent
function createAgent(
logger: PrefixLogger,
projectId: string,
config: z.infer<typeof WorkflowAgent>,
tools: Record<string, Tool>,
projectTools: z.infer<typeof WorkflowTool>[],
@ -539,7 +540,7 @@ ${CHILD_TRANSFER_RELATED_INSTRUCTIONS}
// Add RAG tool if needed
if (config.ragDataSources?.length) {
const ragTool = createRagTool(logger, config, workflow.projectId);
const ragTool = createRagTool(logger, config, projectId);
agentTools.push(ragTool);
// update instructions to include RAG instructions
@ -794,7 +795,12 @@ async function* emitGreetingTurn(logger: PrefixLogger, workflow: z.infer<typeof
yield* emitEvent(logger, new UsageTracker().asEvent());
}
function createTools(logger: PrefixLogger, workflow: z.infer<typeof Workflow>, toolConfig: Record<string, z.infer<typeof WorkflowTool>>): Record<string, Tool> {
function createTools(
logger: PrefixLogger,
projectId: string,
workflow: z.infer<typeof Workflow>,
toolConfig: Record<string, z.infer<typeof WorkflowTool>>,
): Record<string, Tool> {
const tools: Record<string, Tool> = {};
for (const [toolName, config] of Object.entries(toolConfig)) {
if (workflow.mockTools?.[toolName]) {
@ -804,16 +810,16 @@ function createTools(logger: PrefixLogger, workflow: z.infer<typeof Workflow>, t
});
logger.log(`created mock tool: ${toolName}`);
} else if (config.isMcp) {
tools[toolName] = createMcpTool(logger, config, workflow.projectId);
tools[toolName] = createMcpTool(logger, config, projectId);
logger.log(`created mcp tool: ${toolName}`);
} else if (config.isComposio) {
tools[toolName] = createComposioTool(logger, config, workflow.projectId);
tools[toolName] = createComposioTool(logger, config, projectId);
logger.log(`created composio tool: ${toolName}`);
} else if (config.mockTool) {
tools[toolName] = createMockTool(logger, config);
logger.log(`created mock tool: ${toolName}`);
} else {
tools[toolName] = createWebhookTool(logger, config, workflow.projectId);
tools[toolName] = createWebhookTool(logger, config, projectId);
logger.log(`created webhook tool: ${toolName}`);
}
}
@ -822,6 +828,7 @@ function createTools(logger: PrefixLogger, workflow: z.infer<typeof Workflow>, t
function createAgents(
logger: PrefixLogger,
projectId: string,
workflow: z.infer<typeof Workflow>,
agentConfig: Record<string, z.infer<typeof WorkflowAgent>>,
tools: Record<string, Tool>,
@ -837,6 +844,7 @@ function createAgents(
for (const [agentName, config] of Object.entries(agentConfig)) {
const { agent, entities } = createAgent(
logger,
projectId,
config,
tools,
projectTools,
@ -918,6 +926,7 @@ function maybeInjectGiveUpControlInstructions(
// Main function to stream an agentic response
// using OpenAI Agents SDK
export async function* streamResponse(
projectId: string,
workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[],
@ -926,8 +935,7 @@ export async function* streamResponse(
console.log('-------------------- AGENT LOOP START --------------------');
// set up logging
let logger = new PrefixLogger(`agent-loop`)
logger.log('projectId', workflow.projectId);
logger.log('workflow', workflow.name);
logger.log('projectId', projectId);
// ensure valid system message
ensureSystemMessage(logger, messages);
@ -946,10 +954,10 @@ export async function* streamResponse(
logger.log(`initialized stack: ${JSON.stringify(stack)}`);
// create tools
const tools = createTools(logger, workflow, toolConfig);
const tools = createTools(logger, projectId, workflow, toolConfig);
// create agents
const { agents, originalInstructions, originalHandoffs } = createAgents(logger, workflow, agentConfig, tools, projectTools, promptConfig);
const { agents, originalInstructions, originalHandoffs } = createAgents(logger, projectId, workflow, agentConfig, tools, projectTools, promptConfig);
// track agent to agent calls
const transferCounter = new AgentTransferCounter();
@ -1203,6 +1211,7 @@ export async function* streamResponse(
// this is a sync version of streamResponse
export async function getResponse(
projectId: string,
workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[],
@ -1218,7 +1227,7 @@ export async function getResponse(
completion: 0,
},
};
for await (const event of streamResponse(workflow, projectTools, messages)) {
for await (const event of streamResponse(projectId, workflow, projectTools, messages)) {
if ('role' in event) {
out.push(event);
}

View file

@ -11,7 +11,6 @@ export const USE_KLAVIS_TOOLS = process.env.USE_KLAVIS_TOOLS === 'true';
// Hardcoded flags
export const USE_MULTIPLE_PROJECTS = true;
export const USE_TESTING_FEATURE = false;
export const USE_VOICE_FEATURE = false;
export const USE_TRANSFER_CONTROL_OPTIONS = true;
export const USE_PRODUCT_TOUR = true;

View file

@ -0,0 +1,84 @@
import { agentWorkflowsCollection, projectsCollection } from "./mongodb";
import { ObjectId } from "mongodb";
import { Workflow } from "./types/workflow_types";
import { z } from "zod";
export async function migrate_versioned_workflows(projectId: string) {
// fetch project data
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error(`Project ${projectId} not found`);
}
// Skip if project already has workflows migrated
if (project.draftWorkflow && project.liveWorkflow) {
console.log(`Project ${projectId} already has migrated workflows, skipping...`);
return;
}
const updateFields: { draftWorkflow?: z.infer<typeof Workflow>; liveWorkflow?: z.infer<typeof Workflow> } = {};
// 1. Migrate published workflow to liveWorkflow
if (project.publishedWorkflowId) {
const publishedWorkflow = await agentWorkflowsCollection.findOne({
_id: new ObjectId(project.publishedWorkflowId)
});
if (publishedWorkflow) {
// @ts-ignore - Workflow type mismatch
const { _id, name, createdAt, projectId, ...rest } = publishedWorkflow;
updateFields.liveWorkflow = rest;
console.log(`Found published workflow ${project.publishedWorkflowId} for project ${projectId}`);
} else {
console.warn(`Published workflow ${project.publishedWorkflowId} not found for project ${projectId}`);
}
}
// 2. Find the latest workflow for draft (that isn't the published one)
const workflows = await agentWorkflowsCollection.find({
projectId,
}).sort({ lastUpdatedAt: -1 }).toArray();
let latestWorkflow;
for (const workflow of workflows) {
// Skip if this is the published workflow
if (project.publishedWorkflowId && workflow._id.toString() === project.publishedWorkflowId) {
continue;
}
latestWorkflow = workflow;
break;
}
// Handle cases where no published workflow exists
if (!updateFields.liveWorkflow && latestWorkflow) {
// No published workflow found, use latest as live workflow
// @ts-ignore - Workflow type mismatch
const { _id, name, createdAt, projectId, ...rest } = latestWorkflow;
updateFields.liveWorkflow = rest;
console.log(`No published workflow found, using latest workflow as live for project ${projectId}`);
}
// Set draft workflow
if (latestWorkflow) {
// @ts-ignore - Workflow type mismatch
const { _id, name, createdAt, projectId, ...rest } = latestWorkflow;
updateFields.draftWorkflow = rest;
console.log(`Found draft workflow for project ${projectId}`);
} else if (updateFields.liveWorkflow) {
// No separate draft found, use the published workflow as draft too
updateFields.draftWorkflow = updateFields.liveWorkflow;
console.log(`No separate draft found, using live workflow as draft for project ${projectId}`);
}
// 3. Update the project with the migrated workflows
if (Object.keys(updateFields).length > 0) {
await projectsCollection.updateOne(
{ _id: projectId },
{ $set: updateFields }
);
console.log(`Successfully migrated ${Object.keys(updateFields).length} workflow(s) for project ${projectId}`);
} else {
console.log(`No workflows found to migrate for project ${projectId}`);
}
}

View file

@ -7,7 +7,6 @@ import { Project } from "./types/project_types";
import { EmbeddingDoc } from "./types/datasource_types";
import { DataSourceDoc } from "./types/datasource_types";
import { DataSource } from "./types/datasource_types";
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
import { z } from 'zod';
import { apiV1 } from "rowboat-shared";
@ -23,11 +22,6 @@ export const projectMembersCollection = db.collection<z.infer<typeof ProjectMemb
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
export const testScenariosCollection = db.collection<z.infer<typeof TestScenario>>("test_scenarios");
export const testProfilesCollection = db.collection<z.infer<typeof TestProfile>>("test_profiles");
export const testSimulationsCollection = db.collection<z.infer<typeof TestSimulation>>("test_simulations");
export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_runs");
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
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");

View file

@ -1,6 +1,6 @@
import { z } from "zod";
import { MCPServer } from "./types";
import { WorkflowTool } from "./workflow_types";
import { Workflow, WorkflowTool } from "./workflow_types";
import { ZTool } from "../composio/composio";
export const ComposioConnectedAccount = z.object({
@ -23,9 +23,10 @@ export const Project = z.object({
createdByUserId: z.string(),
secret: z.string(),
chatClientId: z.string(),
draftWorkflow: Workflow.optional(),
liveWorkflow: Workflow.optional(),
webhookUrl: z.string().optional(),
publishedWorkflowId: z.string().optional(),
nextWorkflowNumber: z.number().optional(),
testRunCounter: z.number().default(0),
mcpServers: z.array(MCPServer).optional(),
composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(),

View file

@ -1,54 +0,0 @@
import { z } from "zod";
export const TestScenario = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
description: z.string().min(1, "Description cannot be empty"),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestProfile = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
context: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
mockTools: z.boolean(),
mockPrompt: z.string().optional(),
});
export const TestSimulation = z.object({
projectId: z.string(),
name: z.string(),
description: z.string().optional().nullable(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
scenarioId: z.string(),
profileId: z.string().nullable(),
passCriteria: z.string(),
});
export const TestRun = z.object({
projectId: z.string(),
name: z.string(),
simulationIds: z.array(z.string()),
workflowId: z.string(),
status: z.enum(['pending', 'running', 'completed', 'cancelled', 'failed', 'error']),
startedAt: z.string(),
completedAt: z.string().optional(),
aggregateResults: z.object({
total: z.number(),
passCount: z.number(),
failCount: z.number(),
}).optional(),
});
export const TestResult = z.object({
projectId: z.string(),
runId: z.string(),
simulationId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string(),
transcript: z.string()
});

View file

@ -1,5 +1,5 @@
import { z } from "zod";
import { WorkflowTool } from "./workflow_types";
import { Workflow, WorkflowTool } from "./workflow_types";
export const SystemMessage = z.object({
role: z.literal("system"),
@ -158,7 +158,6 @@ export type WithStringId<T> = T & { _id: string };
export const ApiRequest = z.object({
messages: z.array(Message),
state: z.unknown(),
workflowId: z.string().nullable().optional(),
testProfileId: z.string().nullable().optional(),
mockTools: z.record(z.string(), z.string()).nullable().optional(),
});
@ -208,3 +207,9 @@ export function convertMcpServerToolToWorkflowTool(
return converted;
}
export const ZStreamAgentResponsePayload = z.object({
projectId: z.string(),
workflow: Workflow,
projectTools: z.array(WorkflowTool),
messages: z.array(Message),
});

View file

@ -8,7 +8,6 @@ export const TwilioConfigParams = z.object({
auth_token: z.string(),
label: z.string(),
project_id: z.string(),
workflow_id: z.string(),
});
export const TwilioConfig = TwilioConfigParams.extend({
@ -24,7 +23,6 @@ export interface TwilioConfigResponse {
export interface InboundConfigResponse {
status: 'configured' | 'reconfigured';
phone_number: string;
workflow_id: string;
previous_webhook?: string;
error?: string;
}
@ -34,7 +32,6 @@ export const TwilioInboundCall = z.object({
to: z.string(),
from: z.string(),
projectId: z.string(),
workflowId: z.string(),
messages: z.array(Message),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime().optional(),

View file

@ -61,21 +61,16 @@ export const WorkflowTool = z.object({
}).optional(), // the data for the Composio tool, if it is a Composio tool
});
export const Workflow = z.object({
name: z.string().optional(),
agents: z.array(WorkflowAgent),
prompts: z.array(WorkflowPrompt),
tools: z.array(WorkflowTool),
startAgent: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
projectId: z.string(),
mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions
});
export const WorkflowTemplate = Workflow
.omit({
projectId: true,
lastUpdatedAt: true,
createdAt: true,
})
.extend({
name: z.string(),

View file

@ -3,27 +3,30 @@ import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { redisClient } from "./redis";
import { Workflow, WorkflowTool } from "./types/workflow_types";
import { Message } from "./types/types";
import { Message, ZStreamAgentResponsePayload } from "./types/types";
export async function getAgenticResponseStreamId(
projectId: string,
workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[],
): Promise<{
streamId: string,
}> {
// serialize the request
const payload = JSON.stringify({
const payload: z.infer<typeof ZStreamAgentResponsePayload> = {
projectId,
workflow,
projectTools,
messages,
});
}
// serialize the request
const serialized = JSON.stringify(payload);
// create a uuid for the stream
const streamId = crypto.randomUUID();
// store payload in redis
await redisClient.set(`chat-stream-${streamId}`, payload, 'EX', 60 * 10); // expire in 10 minutes
await redisClient.set(`chat-stream-${streamId}`, serialized, 'EX', 60 * 10); // expire in 10 minutes
return {
streamId,

View file

@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { configureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../../actions/voice_actions";
import { TwilioConfig } from "../../../../lib/types/voice_types";
import { TwilioConfig, TwilioConfigParams } from "../../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react";
import { Section } from './project';
import { clsx } from 'clsx';
@ -198,23 +198,15 @@ export function VoiceSection({ projectId }: { projectId: string }) {
return;
}
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (!workflowId) {
setError('No workflow selected. Please select a workflow first.');
setConfigurationValid(false);
return;
}
setLoading(true);
setError(null);
const configParams = {
const configParams: z.infer<typeof TwilioConfigParams> = {
phone_number: formState.phone.replaceAll(/[^0-9\+]/g, ''),
account_sid: formState.accountSid,
auth_token: formState.authToken,
label: formState.label,
project_id: projectId,
workflow_id: workflowId,
};
const result = await configureTwilioNumber(configParams);

View file

@ -7,11 +7,8 @@ import { Chat } from "./components/chat";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@heroui/react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { CheckIcon, CopyIcon, PlusIcon, UserIcon, InfoIcon, BugIcon, BugOffIcon, CodeIcon } from "lucide-react";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { clsx } from "clsx";
const defaultSystemMessage = '';
@ -40,7 +37,6 @@ export function App({
triggerCopilotChat?: (message: string) => void;
}) {
const [counter, setCounter] = useState<number>(0);
const [testProfile, setTestProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [systemMessage, setSystemMessage] = useState<string>(defaultSystemMessage);
const [showDebugMessages, setShowDebugMessages] = useState<boolean>(true);
const [chat, setChat] = useState<z.infer<typeof PlaygroundChat>>({
@ -59,11 +55,6 @@ export function App({
setCounter(counter + 1);
}
function handleTestProfileChange(profile: WithStringId<z.infer<typeof TestProfile>> | null) {
setTestProfile(profile);
setCounter(counter + 1);
}
function handleNewChatButtonClick() {
setCounter(counter + 1);
setChat({
@ -138,17 +129,6 @@ export function App({
}
rightActions={
<div className="flex items-center gap-3">
{USE_TESTING_FEATURE && (
<Button
variant="secondary"
size="sm"
onClick={() => setIsProfileSelectorOpen(true)}
showHoverContent={true}
hoverContent={testProfile?.name || 'Select test profile'}
>
<UserIcon className="w-4 h-4" />
</Button>
)}
<Button
variant="secondary"
size="sm"
@ -166,22 +146,13 @@ export function App({
}
onClick={onPanelClick}
>
<ProfileSelector
projectId={projectId}
isOpen={isProfileSelectorOpen}
onOpenChange={setIsProfileSelectorOpen}
onSelect={handleTestProfileChange}
selectedProfileId={testProfile?._id}
/>
<div className="h-full overflow-auto px-4 py-4">
<Chat
key={`chat-${counter}`}
chat={chat}
projectId={projectId}
workflow={workflow}
testProfile={testProfile}
messageSubscriber={messageSubscriber}
onTestProfileChange={handleTestProfileChange}
systemMessage={systemMessage}
onSystemMessageChange={handleSystemMessageChange}
mcpServerUrls={mcpServerUrls}

View file

@ -7,10 +7,8 @@ import { MCPServer, Message, PlaygroundChat, ToolMessage } from "@/app/lib/types
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { ComposeBoxPlayground } from "@/components/common/compose-box-playground";
import { Button } from "@heroui/react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ProfileContextBox } from "./profile-context-box";
import { USE_TESTING_FEATURE } from "@/app/lib/feature_flags";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { FeedbackModal } from "./feedback-modal";
@ -21,8 +19,6 @@ export function Chat({
projectId,
workflow,
messageSubscriber,
testProfile = null,
onTestProfileChange,
systemMessage,
onSystemMessageChange,
mcpServerUrls,
@ -37,8 +33,6 @@ export function Chat({
projectId: string;
workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof Message>[]) => void;
testProfile?: z.infer<typeof TestProfile> | null;
onTestProfileChange: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
systemMessage: string;
onSystemMessageChange: (message: string) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
@ -186,6 +180,7 @@ export function Chat({
let streamId: string | null = null;
try {
const response = await getAssistantResponseStreamId(
projectId,
workflow,
projectTools,
[
@ -306,20 +301,12 @@ export function Chat({
systemMessage,
mcpServerUrls,
toolWebhookUrl,
testProfile,
fetchResponseError,
projectTools,
]);
return <div className="w-11/12 max-w-6xl mx-auto h-full flex flex-col relative">
<div className="sticky top-0 z-10 bg-white dark:bg-zinc-900 pt-4 pb-4">
{USE_TESTING_FEATURE && (
<ProfileContextBox
content={testProfile?.context || systemMessage || ''}
onChange={onSystemMessageChange}
locked={testProfile !== null}
/>
)}
</div>
<div
@ -334,7 +321,6 @@ export function Chat({
toolCallResults={toolCallResults}
loadingAssistantResponse={loadingAssistantResponse}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
onSystemMessageChange={onSystemMessageChange}
showSystemMessage={false}

View file

@ -6,7 +6,6 @@ import { Workflow } from "@/app/lib/types/workflow_types";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content";
import { ChevronRightIcon, ChevronDownIcon, ChevronUpIcon, CodeIcon, CheckCircleIcon, FileTextIcon, EyeIcon, EyeOffIcon, WrapTextIcon, ArrowRightFromLineIcon, BracesIcon, TextIcon, FlagIcon } from "lucide-react";
import { TestProfile } from "@/app/lib/types/testing_types";
import { ProfileContextBox } from "./profile-context-box";
import { Message, ToolMessage, AssistantMessageWithToolCalls } from "@/app/lib/types/types";
@ -214,7 +213,6 @@ function ToolCalls({
messages,
sender,
workflow,
testProfile = null,
systemMessage,
delta,
onFix,
@ -227,7 +225,6 @@ function ToolCalls({
messages: z.infer<typeof Message>[];
sender: string | null | undefined;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
delta: number;
onFix?: (message: string) => void;
@ -576,7 +573,6 @@ export function Messages({
toolCallResults,
loadingAssistantResponse,
workflow,
testProfile = null,
systemMessage,
onSystemMessageChange,
showSystemMessage,
@ -589,7 +585,6 @@ export function Messages({
toolCallResults: Record<string, z.infer<typeof ToolMessage>>;
loadingAssistantResponse: boolean;
workflow: z.infer<typeof Workflow>;
testProfile: z.infer<typeof TestProfile> | null;
systemMessage: string | undefined;
onSystemMessageChange: (message: string) => void;
showSystemMessage: boolean;
@ -630,7 +625,6 @@ export function Messages({
messages={messages}
sender={message.agentName ?? ''}
workflow={workflow}
testProfile={testProfile}
systemMessage={systemMessage}
delta={latency}
onFix={onFix}
@ -694,9 +688,8 @@ export function Messages({
if (showSystemMessage) {
return (
<ProfileContextBox
content={testProfile?.context || systemMessage || ''}
content={systemMessage || ''}
onChange={onSystemMessageChange}
locked={testProfile !== null}
/>
);
}

View file

@ -1,67 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ScenariosApp } from "./scenarios_app";
import { ProfilesApp } from "./profiles_app";
import { SimulationsApp } from "./simulations_app";
import { usePathname } from "next/navigation";
import { RunsApp } from "./runs_app";
import { StructuredPanel } from "../../../../lib/components/structured-panel";
import { ListItem } from "../../../../lib/components/structured-list";
export function App({
projectId,
slug
}: {
projectId: string,
slug?: string[]
}) {
const router = useRouter();
const pathname = usePathname();
let selection: "scenarios" | "profiles" | "criteria" | "simulations" | "runs" = "runs";
if (!slug || slug.length === 0) {
router.push(`/projects/${projectId}/test/runs`);
} else if (slug[0] === "scenarios") {
selection = "scenarios";
} else if (slug[0] === "profiles") {
selection = "profiles";
} else if (slug[0] === "criteria") {
selection = "criteria";
} else if (slug[0] === "simulations") {
selection = "simulations";
} else if (slug[0] === "runs") {
selection = "runs";
}
let innerSlug: string[] = [];
if (slug && slug.length > 1) {
innerSlug = slug.slice(1);
}
const menuItems = [
{ label: "Scenarios", href: `/projects/${projectId}/test/scenarios` },
{ label: "Profiles", href: `/projects/${projectId}/test/profiles` },
{ label: "Simulations", href: `/projects/${projectId}/test/simulations` },
{ label: "Test Runs", href: `/projects/${projectId}/test/runs` },
];
return <div className="flex h-full">
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
<div className="overflow-auto flex flex-col gap-1 justify-start">
{menuItems.map((item) => (
<ListItem
key={item.label}
name={item.label}
isSelected={pathname.startsWith(item.href)}
onClick={() => router.push(item.href)}
/>
))}
</div>
</StructuredPanel>
<div className="grow border-l border-gray-200 dark:border-neutral-800 p-2">
{selection === "scenarios" && <ScenariosApp projectId={projectId} slug={innerSlug} />}
{selection === "profiles" && <ProfilesApp projectId={projectId} slug={innerSlug} />}
{selection === "simulations" && <SimulationsApp projectId={projectId} slug={innerSlug} />}
{selection === "runs" && <RunsApp projectId={projectId} slug={innerSlug} />}
</div>
</div>;
}

View file

@ -1,38 +0,0 @@
// First, let's create a reusable component for item views
export function ItemView({
items,
actions
}: {
items: { label: string; value: string | React.ReactNode }[];
actions: React.ReactNode;
}) {
return (
<div className="max-w-3xl">
{/* Content */}
<div className="bg-white dark:bg-neutral-950 rounded-lg border border-gray-200 dark:border-neutral-800 overflow-hidden">
<div className="divide-y divide-gray-100 dark:divide-neutral-800">
{items.map((item, index) => (
<div
key={index}
className="px-6 py-4 flex flex-col gap-1"
>
<dt className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-neutral-400">
{item.label}
</dt>
<dd className="text-sm text-gray-900 dark:text-white">
{item.value || "—"}
</dd>
</div>
))}
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 dark:bg-neutral-900 border-t border-gray-200 dark:border-neutral-800">
<div className="flex gap-2">
{actions}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,90 +0,0 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea, Switch } from "@heroui/react"
import { useRef, useState } from "react";
interface ProfileFormProps {
defaultValues?: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
};
formRef: React.RefObject<HTMLFormElement | null>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
}
export function ProfileForm({
defaultValues = {},
formRef,
handleSubmit,
onCancel,
submitButtonText,
}: ProfileFormProps) {
const [mockTools, setMockTools] = useState(Boolean(defaultValues.mockTools));
const [showMockPrompt, setShowMockPrompt] = useState(Boolean(defaultValues.mockTools));
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
<Input
label="Name"
name="name"
placeholder="Provide a name to describe the user's profile to simulate, e.g. &quot;Frequent buyer&quot;"
defaultValue={defaultValues.name}
isRequired
/>
<Textarea
label="Context"
name="context"
placeholder="Provide user info and other info to simulate, e.g. &quot;User's name: John Smith. Buying frequency: 10 orders a month. Location: US. Latest order: Pair of Jeans - XL.&quot;"
defaultValue={defaultValues.context}
isRequired
/>
<Switch
isSelected={mockTools}
onValueChange={(checked) => {
setMockTools(checked);
setShowMockPrompt(checked);
}}
name="mockTools"
value="on"
>
Mock Tools
</Switch>
{showMockPrompt && (
<div className="rounded-lg border border-gray-200 dark:border-neutral-800 p-4">
<div className="text-sm font-medium mb-2">Mock Prompt (Optional)</div>
<Textarea
name="mockPrompt"
placeholder="Enter a mock prompt"
defaultValue={defaultValues.mockPrompt}
/>
</div>
)}
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
</form>
);
}

View file

@ -1,68 +0,0 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea } from "@heroui/react";
interface ScenarioFormProps {
formRef: React.RefObject<HTMLFormElement | null>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
defaultValues?: {
name?: string;
description?: string;
};
}
export function ScenarioForm({
formRef,
handleSubmit,
onCancel,
submitButtonText,
defaultValues = {},
}: ScenarioFormProps) {
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
<Input
type="text"
name="name"
label="Name"
placeholder="Provide a name for this scenario, e.g. &quot;Order cancellation&quot;"
defaultValue={defaultValues.name}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<Textarea
name="description"
label="Description"
placeholder="Describe the scenario that should be simulated, e.g. &quot;Role play a user who wants to cancel their recently ordered pair of jeans.&quot;"
defaultValue={defaultValues.description}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
</form>
);
}

View file

@ -1,134 +0,0 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listProfiles } from "@/app/actions/testing_actions";
import { Button } from "@/components/ui/button";
import { Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { useRouter } from "next/navigation";
interface ProfileSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
selectedProfileId?: string;
}
export function ProfileSelector({ projectId, isOpen, onOpenChange, onSelect, selectedProfileId }: ProfileSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const router = useRouter();
const fetchProfiles = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listProfiles(projectId, page, pageSize);
setProfiles(result.profiles);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch profiles: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchProfiles(page);
}
}, [page, isOpen, fetchProfiles]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Profile</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" variant="primary" onClick={() => fetchProfiles(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-6 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-sm rounded-t-md">
<div className="col-span-2 px-4 text-gray-700 dark:text-gray-300">Name</div>
<div className="col-span-3 px-4 text-gray-700 dark:text-gray-300">Context</div>
<div className="col-span-1 px-4 text-gray-700 dark:text-gray-300">Mock Tools</div>
</div>
{profiles.map((p) => (
<div
key={p._id}
className={`grid grid-cols-6 py-2.5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm cursor-pointer transition-colors ${
p._id === selectedProfileId
? 'bg-blue-100 dark:bg-blue-900/50 border-blue-200 dark:border-blue-800'
: ''
}`}
onClick={() => {
onSelect(p);
onClose();
}}
>
<div className="col-span-2 px-4 truncate text-gray-900 dark:text-gray-100">{p.name}</div>
<div className="col-span-3 px-4 truncate text-gray-600 dark:text-gray-400">{p.context}</div>
<div className="col-span-1 px-4 text-gray-600 dark:text-gray-400">{p.mockTools ? "Yes" : "No"}</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<div className="flex items-center gap-4 w-full">
<div className="flex-1">
<Button
size="sm"
variant="primary"
onClick={() => router.push(`/projects/${projectId}/test/profiles`)}
>
Manage Profiles
</Button>
</div>
<div className="flex items-center gap-3">
{selectedProfileId && (
<Button
size="sm"
variant="tertiary"
className="text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
onClick={() => {
onSelect(null);
onClose();
}}
>
Clear Selection
</Button>
)}
<Button size="sm" variant="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</div>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -1,109 +0,0 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listScenarios } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { useRouter } from "next/navigation";
interface ScenarioSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (scenario: WithStringId<z.infer<typeof TestScenario>>) => void;
}
export function ScenarioSelector({ projectId, isOpen, onOpenChange, onSelect }: ScenarioSelectorProps) {
const router = useRouter();
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 10;
const fetchScenarios = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listScenarios(projectId, page, pageSize);
setScenarios(result.scenarios);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch scenarios: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchScenarios(page);
}
}, [page, isOpen, fetchScenarios]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Scenario</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchScenarios(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-5 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-sm">
<div className="col-span-2 px-4 text-gray-900 dark:text-gray-100">Name</div>
<div className="col-span-3 px-4 text-gray-900 dark:text-gray-100">Description</div>
</div>
{scenarios.map((s) => (
<div
key={s._id}
className="grid grid-cols-5 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer"
onClick={() => {
onSelect(s);
onClose();
}}
>
<div className="col-span-2 px-4 truncate">{s.name}</div>
<div className="col-span-3 px-4 truncate">{s.description}</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<div className="flex gap-2">
<Button
size="sm"
color="primary"
onPress={() => router.push(`/projects/${projectId}/test/scenarios`)}
>
Manage Scenarios
</Button>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</div>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -1,140 +0,0 @@
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation } from "@/app/lib/types/testing_types";
import { useCallback, useEffect, useState } from "react";
import { listSimulations } from "@/app/actions/testing_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Chip } from "@heroui/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
interface SimulationSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (simulations: WithStringId<z.infer<typeof TestSimulation>>[]) => void;
initialSelected?: WithStringId<z.infer<typeof TestSimulation>>[];
}
export function SimulationSelector({ projectId, isOpen, onOpenChange, onSelect, initialSelected = [] }: SimulationSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [selectedSimulations, setSelectedSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>(initialSelected);
const pageSize = 3;
const fetchSimulations = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listSimulations(projectId, page, pageSize);
setSimulations(result.simulations);
setTotalPages(Math.ceil(result.total / pageSize));
} catch (error) {
setError(`Unable to fetch simulations: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchSimulations(page);
}
}, [page, isOpen, fetchSimulations]);
const handleSelect = (simulation: WithStringId<z.infer<typeof TestSimulation>>) => {
const isSelected = selectedSimulations.some(s => s._id === simulation._id);
let newSelected;
if (isSelected) {
newSelected = selectedSimulations.filter(s => s._id !== simulation._id);
} else {
newSelected = [...selectedSimulations, simulation];
}
setSelectedSimulations(newSelected);
onSelect(newSelected);
};
const handleRemove = (simulationId: string) => {
const newSelected = selectedSimulations.filter(s => s._id !== simulationId);
setSelectedSimulations(newSelected);
onSelect(newSelected);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select Simulations</ModalHeader>
<ModalBody>
{selectedSimulations.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{selectedSimulations.map((sim) => (
<Chip
key={sim._id}
onClose={() => handleRemove(sim._id)}
variant="flat"
className="py-1"
>
{sim.name}
</Chip>
))}
</div>
)}
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchSimulations(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{simulations.length === 0 && <div className="text-gray-600 text-center">No simulations found</div>}
{simulations.length > 0 && <div className="flex flex-col w-full">
<div className="grid grid-cols-8 py-2 bg-gray-100 font-semibold text-sm">
<div className="col-span-3 px-4">Name</div>
<div className="col-span-3 px-4">Pass Criteria</div>
<div className="col-span-2 px-4">Last Updated</div>
</div>
{simulations.map((sim) => {
const isSelected = selectedSimulations.some(s => s._id === sim._id);
return (
<div
key={sim._id}
className={`grid grid-cols-8 py-2 border-b hover:bg-gray-50 text-sm cursor-pointer ${
isSelected ? 'bg-blue-50 hover:bg-blue-100' : ''
}`}
onClick={() => handleSelect(sim)}
>
<div className="col-span-3 px-4 truncate">{sim.name}</div>
<div className="col-span-3 px-4 truncate">{sim.passCriteria || '-'}</div>
<div className="col-span-2 px-4 truncate">
<RelativeTime date={new Date(sim.lastUpdatedAt)} />
</div>
</div>
);
})}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Done
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -1,106 +0,0 @@
import { WithStringId } from "@/app/lib/types/types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { useCallback, useEffect, useState } from "react";
import { listWorkflows } from "@/app/actions/workflow_actions";
import { Button, Pagination, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { WorkflowIcon } from "../../../../../../lib/components/icons";
import { PublishedBadge } from "@/app/projects/[projectId]/workflow/published_badge";
interface WorkflowSelectorProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (workflow: WithStringId<z.infer<typeof Workflow>>) => void;
}
export function WorkflowSelector({ projectId, isOpen, onOpenChange, onSelect }: WorkflowSelectorProps) {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workflows, setWorkflows] = useState<WithStringId<z.infer<typeof Workflow>>[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const pageSize = 10;
const fetchWorkflows = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await listWorkflows(projectId, page, pageSize);
setWorkflows(result.workflows);
setTotalPages(Math.ceil(result.total / pageSize));
setPublishedWorkflowId(result.publishedWorkflowId);
} catch (error) {
setError(`Unable to fetch workflows: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (isOpen) {
fetchWorkflows(page);
}
}, [page, isOpen, fetchWorkflows]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Select a Workflow</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => fetchWorkflows(page)}>Retry</Button>
</div>}
{!loading && !error && <>
{workflows.length === 0 && <div className="text-gray-600 text-center">No workflows found</div>}
{workflows.length > 0 && <div className="flex flex-col gap-2">
{workflows.map((workflow) => (
<div
key={workflow._id}
className="flex items-center justify-between p-3 border rounded-md hover:bg-gray-50 cursor-pointer"
onClick={() => {
onSelect(workflow);
onClose();
}}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<WorkflowIcon />
<span className="font-medium">{workflow.name || 'Unnamed workflow'}</span>
{publishedWorkflowId === workflow._id && <PublishedBadge />}
</div>
<div className="text-xs text-gray-500">
Updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
</div>
</div>
</div>
))}
</div>}
{totalPages > 1 && <Pagination
total={totalPages}
page={page}
onChange={setPage}
className="self-center"
/>}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -1,190 +0,0 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button-old";
import { Button, Input, Textarea } from "@heroui/react";
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ScenarioSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/scenario-selector";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { z } from "zod";
interface SimulationFormProps {
formRef: React.RefObject<HTMLFormElement | null>;
handleSubmit: (formData: FormData) => Promise<void>;
scenario: WithStringId<z.infer<typeof TestScenario>> | null;
setScenario: (scenario: WithStringId<z.infer<typeof TestScenario>> | null) => void;
profile: WithStringId<z.infer<typeof TestProfile>> | null;
setProfile: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
isScenarioModalOpen: boolean;
setIsScenarioModalOpen: (isOpen: boolean) => void;
isProfileModalOpen: boolean;
setIsProfileModalOpen: (isOpen: boolean) => void;
projectId: string;
submitButtonText: string;
defaultValues?: {
name?: string;
description?: string;
passCriteria?: string;
};
onCancel: () => void;
}
export function SimulationForm({
formRef,
handleSubmit,
scenario,
setScenario,
profile,
setProfile,
isScenarioModalOpen,
setIsScenarioModalOpen,
isProfileModalOpen,
setIsProfileModalOpen,
projectId,
submitButtonText,
defaultValues = {},
onCancel,
}: SimulationFormProps) {
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
{/* Basic Information */}
<div className="flex flex-col gap-4 p-4 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-sm font-medium">Basic Information</h2>
<Input
type="text"
name="name"
label={<span>Name</span>}
placeholder="Enter a name for the simulation, e.g. &quot;Frequent buyer cancelling order&quot;"
defaultValue={defaultValues.name}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<Textarea
name="description"
label={<span>Description</span>}
placeholder="Enter an optional description for the simulation, just to help you remember what it's for"
defaultValue={defaultValues.description}
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
</div>
{/* Test Configuration */}
<div className="flex flex-col gap-6 p-6 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Test Configuration</h2>
<div className="flex flex-col gap-6">
{/* Scenario Selection */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Scenario <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-1.5 min-h-[2rem]">
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
{scenario ? (
<span className="text-blue-600 dark:text-blue-400">{scenario.name}</span>
) : (
<span className="text-red-500">No scenario selected</span>
)}
</div>
<Button
size="sm"
onPress={() => setIsScenarioModalOpen(true)}
type="button"
>
{scenario ? "Change" : "Select"} Scenario
</Button>
</div>
</div>
{/* Profile Selection */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Profile <span className="text-gray-500 dark:text-neutral-400">(optional)</span>
</label>
<div className="flex items-center gap-1.5 min-h-[2rem]">
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
{profile ? (
<span className="text-blue-600 dark:text-blue-400">{profile.name}</span>
) : (
"No profile selected"
)}
</div>
<div className="flex gap-2">
{profile && (
<Button size="sm" variant="bordered" onClick={() => setProfile(null)}>
Remove
</Button>
)}
<Button
size="sm"
onPress={() => setIsProfileModalOpen(true)}
type="button"
>
{profile ? "Change" : "Select"} Profile
</Button>
</div>
</div>
</div>
{/* Pass Criteria */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Pass Criteria <span className="text-red-500">*</span>
</label>
<Textarea
name="passCriteria"
placeholder="Define the criteria for this test to pass, e.g. &quot;The assistant should successfully cancel the user's order and provide next steps for the user to confirm the cancellation&quot;"
defaultValue={defaultValues.passCriteria}
isRequired
minRows={3}
classNames={{
base: "w-full",
input: "bg-white dark:bg-neutral-900 resize-none",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:border-gray-300 dark:hover:border-neutral-700 transition-colors"
}}
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
isDisabled: !scenario,
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
<ScenarioSelector
projectId={projectId}
isOpen={isScenarioModalOpen}
onOpenChange={setIsScenarioModalOpen}
onSelect={setScenario}
/>
<ProfileSelector
projectId={projectId}
isOpen={isProfileModalOpen}
onOpenChange={setIsProfileModalOpen}
onSelect={setProfile}
/>
</form>
);
}

View file

@ -1,321 +0,0 @@
import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, Selection } from "@heroui/react";
import { Button } from "@heroui/react";
import { PencilIcon, TrashIcon, EyeIcon, DownloadIcon } from "lucide-react";
import Link from "next/link";
import { ReactNode, useState } from "react";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
// Helper function to safely parse dates
const isValidDate = (date: any): boolean => {
const parsed = new Date(date);
return parsed instanceof Date && !isNaN(parsed.getTime());
};
interface Column {
key: string;
label: string;
render?: (item: any) => ReactNode;
}
interface DataTableProps {
items: any[];
columns: Column[];
selectedKeys?: Selection;
onSelectionChange?: (keys: Selection) => void;
projectId: string;
onDelete?: (id: string) => Promise<void>;
onEdit?: (id: string) => void;
onView?: (id: string) => void;
onDownload?: (id: string) => void;
selectionMode?: "multiple" | "none";
}
export function DataTable({
items,
columns,
selectedKeys,
onSelectionChange,
projectId,
onDelete,
onEdit,
onView,
onDownload,
selectionMode = "multiple",
}: DataTableProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [isDeleteAllModalOpen, setIsDeleteAllModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDeleteClick = (id: string) => {
setItemToDelete(id);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!itemToDelete || !onDelete) return;
try {
await onDelete(itemToDelete);
setIsDeleteModalOpen(false);
setItemToDelete(null);
} catch (error) {
setDeleteError(`Failed to delete: ${error}`);
}
};
const handleDeleteAll = async () => {
if (!onDelete) return;
try {
// Delete all items sequentially
for (const item of items) {
await onDelete(item._id);
}
setIsDeleteAllModalOpen(false);
// Selection will be cleared automatically when items refresh
} catch (error) {
setDeleteError(`Failed to delete items: ${error}`);
}
};
const isAllSelected = selectedKeys === "all";
const renderCells = (item: any) => {
const cells = columns.map(column => (
<TableCell key={column.key}>
{column.render ? column.render(item) :
// Handle date fields specially
(column.key.toLowerCase().includes('date') ||
column.key === 'createdAt' ||
column.key === 'lastUpdatedAt') && isValidDate(item[column.key]) ?
new Date(item[column.key]).toLocaleString() :
item[column.key]
}
</TableCell>
));
// Only add actions column if there are any actions
const hasActions = onDelete || onEdit || onView || onDownload;
if (hasActions) {
cells.push(
<TableCell key="actions">
<div className="flex items-center gap-0.5">
{onView && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onView(item._id)}
aria-label="View item"
>
<EyeIcon size={16} />
</Button>
)}
{onEdit && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onEdit(item._id)}
aria-label="Edit item"
>
<PencilIcon size={16} />
</Button>
)}
{onDownload && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onDownload(item._id)}
aria-label="Download results"
>
<DownloadIcon size={16} />
</Button>
)}
{onDelete && (
<Button
isIconOnly
size="sm"
variant="light"
color="danger"
onPress={() => handleDeleteClick(item._id)}
aria-label="Delete item"
>
<TrashIcon size={16} />
</Button>
)}
</div>
</TableCell>
);
}
return cells;
};
return (
<>
<div className="flex flex-col gap-4">
{/* Only show Delete All button when selection is enabled and items are selected */}
{selectionMode === "multiple" && selectedKeys === "all" && items.length > 0 && (
<div className="flex justify-start">
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteAllModalOpen(true)}
startContent={<TrashIcon size={16} />}
>
Delete All ({items.length})
</Button>
</div>
)}
<Table
selectedKeys={selectionMode === "multiple" ? selectedKeys : undefined}
onSelectionChange={selectionMode === "multiple" ? onSelectionChange : undefined}
aria-label="Data table"
classNames={{
base: "max-h-[400px] overflow-auto",
table: "min-w-full",
}}
selectionMode={selectionMode}
>
<TableHeader columns={[
...columns.map(column => ({
key: column.key,
label: column.label
})),
...((onDelete || onEdit || onView || onDownload) ? [{
key: 'actions',
label: 'ACTIONS',
render: (item: any) => (
<div className="flex items-center gap-0.5">
<Button
isIconOnly
size="sm"
variant="light"
>
<PencilIcon size={16} />
</Button>
<Button
isIconOnly
size="sm"
variant="light"
color="danger"
>
<TrashIcon size={16} />
</Button>
</div>
),
}] : [])
]}>
{(column) => (
<TableColumn key={column.key}>{column.label}</TableColumn>
)}
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow key={item._id}>
{renderCells(item)}
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Single Delete Confirmation Modal */}
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={(open) => {
setIsDeleteModalOpen(open);
if (!open) setItemToDelete(null);
}}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this item?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDeleteConfirm();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* Delete All Confirmation Modal */}
<Modal
isOpen={isDeleteAllModalOpen}
onOpenChange={setIsDeleteAllModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Delete All</ModalHeader>
<ModalBody>
Are you sure you want to delete all {items.length} items? This action cannot be undone.
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDeleteAll();
onClose();
}}
>
Delete All
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* Error Modal */}
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button size="sm" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}

View file

@ -1,45 +0,0 @@
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ScenariosApp } from "./scenarios_app";
import { SimulationsApp } from "./simulations_app";
import { ProfilesApp } from "./profiles_app";
import { RunsApp } from "./runs_app";
import { TestingMenu } from "./testing_menu";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
export default async function TestPage(props: { params: Promise<{ projectId: string; slug?: string[] }> }) {
const params = await props.params;
await requireActiveBillingSubscription();
const { projectId, slug = [] } = params;
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";
if (slug[0] === "scenarios") {
app = "scenarios";
} else if (slug[0] === "simulations") {
app = "simulations";
} else if (slug[0] === "profiles") {
app = "profiles";
} else if (slug[0] === "runs") {
app = "runs";
}
return (
<div className="h-full flex flex-col">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={15} minSize={10}>
<div className="h-full border-r border-gray-200 dark:border-neutral-800">
<TestingMenu projectId={projectId} app={app} />
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={85}>
{app === "scenarios" && <ScenariosApp projectId={projectId} slug={slug.slice(1)} />}
{app === "simulations" && <SimulationsApp projectId={projectId} slug={slug.slice(1)} />}
{app === "profiles" && <ProfilesApp projectId={projectId} slug={slug.slice(1)} />}
{app === "runs" && <RunsApp projectId={projectId} slug={slug.slice(1)} />}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View file

@ -1,335 +0,0 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createProfile, getProfile, listProfiles, updateProfile, deleteProfile } from "@/app/actions/testing_actions";
import { Button, Spinner, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PlusIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { DataTable } from "./components/table";
import { isValidDate } from './utils/date';
import { ProfileForm } from "./components/profile-form";
function EditProfile({
projectId,
profileId,
}: {
projectId: string,
profileId: string,
}) {
const router = useRouter();
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [loading, setLoading] = useState(true);
const [mockTools, setMockTools] = useState(false);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
async function fetchProfile() {
setError(null);
try {
const profile = await getProfile(projectId, profileId);
setProfile(profile);
setMockTools(profile?.mockTools || false);
} catch (error) {
setError(`Unable to fetch profile: ${error}`);
} finally {
setLoading(false);
}
}
fetchProfile();
}, [profileId, projectId]);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockTools = formData.get("mockTools") === "on";
const mockPrompt = formData.get("mockPrompt") as string;
await updateProfile(projectId, profileId, {
name,
context,
mockTools,
mockPrompt: mockTools && mockPrompt ? mockPrompt : undefined
});
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setError(`Unable to update profile: ${error}`);
}
}
return <StructuredPanel
title="EDIT PROFILE"
tooltip="Edit an existing test profile"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading profile...
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{!loading && profile && (
<ProfileForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
submitButtonText="Update Profile"
defaultValues={{
name: profile.name,
context: profile.context,
mockTools: Boolean(profile.mockTools),
mockPrompt: profile.mockPrompt || ""
}}
/>
)}
</div>
</StructuredPanel>;
}
function NewProfile({ projectId }: { projectId: string }) {
const formRef = useRef<HTMLFormElement>(null);
const router = useRouter();
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockTools = formData.get("mockTools") === "on";
const mockPrompt = mockTools ? (formData.get("mockPrompt") as string) : undefined;
await createProfile(projectId, {
name,
context,
mockTools,
mockPrompt // This will be undefined if mockTools is false
});
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setError(`Unable to create profile: ${error}`);
}
}
return <StructuredPanel
title="NEW PROFILE"
tooltip="Create a new test profile"
>
<div className="flex flex-col gap-6 max-w-2xl">
<ProfileForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
submitButtonText="Create Profile"
/>
</div>
</StructuredPanel>;
}
function ProfileList({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
useEffect(() => {
let ignore = false;
async function fetchProfiles() {
setLoading(true);
setError(null);
try {
const profiles = await listProfiles(projectId, page, pageSize);
if (!ignore) {
setProfiles(profiles.profiles);
setTotal(Math.ceil(profiles.total / pageSize));
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch profiles: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchProfiles();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedProfiles([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedProfiles(profiles.map(profile => profile._id));
} else {
setSelectedProfiles(Array.from(selection as Set<string>));
}
}
};
const handleDelete = async (profileId: string) => {
try {
await deleteProfile(projectId, profileId);
// Refresh the profiles list after deletion
const result = await listProfiles(projectId, page, pageSize);
setProfiles(result.profiles);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete profile: ${err}`);
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (profile: any) => profile.name
},
{
key: 'context',
label: 'CONTEXT'
},
{
key: 'mockTools',
label: 'MOCK TOOLS',
render: (profile: any) => profile.mockTools ? "Yes" : "No"
},
{
key: 'createdAt',
label: 'CREATED',
render: (profile: any) => profile?.createdAt && isValidDate(profile.createdAt) ?
<RelativeTime date={new Date(profile.createdAt)} /> :
'Invalid date'
},
{
key: 'lastUpdatedAt',
label: 'LAST UPDATED',
render: (profile: any) => profile?.lastUpdatedAt && isValidDate(profile.lastUpdatedAt) ?
<RelativeTime date={new Date(profile.lastUpdatedAt)} /> :
'Invalid date'
}
];
return <StructuredPanel
title="PROFILES"
tooltip="View and manage your test profiles"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Profiles</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Create and manage test profiles for your simulations
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
>
New Profile
</Button>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Profiles Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading profiles...
</div>
) : profiles.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No profiles created yet</p>
</div>
) : (
<DataTable
items={profiles}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/profiles/${id}/edit`)}
projectId={projectId}
/>
)}
</div>
</StructuredPanel>;
}
export function ProfilesApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let profileId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[1] === "edit") {
selection = "edit";
profileId = slug[0];
} else {
selection = "list";
profileId = slug[0];
}
}
return (
<div className="h-full">
{selection === "list" && <ProfileList projectId={projectId} />}
{selection === "new" && <NewProfile projectId={projectId} />}
{selection === "edit" && profileId && (
<EditProfile projectId={projectId} profileId={profileId} />
)}
</div>
);
}
export { NewProfile, EditProfile };

View file

@ -1,457 +0,0 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
import { useEffect, useState } from "react";
import { getRun, getSimulation, listRuns, cancelRun, deleteRun, getSimulationResult, listRunSimulations } from "@/app/actions/testing_actions";
import { Button, Spinner, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon, DownloadIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { Workflow } from "@/app/lib/types/workflow_types";
import { fetchWorkflow } from "@/app/actions/workflow_actions";
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel"
import { DataTable } from "./components/table"
import { isValidDate } from './utils/date';
function ViewRun({
projectId,
runId,
}: {
projectId: string,
runId: string,
}) {
const router = useRouter();
const [run, setRun] = useState<WithStringId<z.infer<typeof TestRun>> | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
useEffect(() => {
async function fetchData() {
try {
const run = await getRun(projectId, runId);
if (!run) {
setError("Run not found");
return;
}
setRun(run);
const enrichedSimulations = await listRunSimulations(projectId, run.simulationIds);
setSimulations(enrichedSimulations);
// Fetch workflow and simulations in parallel
const [workflowResult, simulationsResult] = await Promise.all([
fetchWorkflow(projectId, run.workflowId),
Promise.all(run.simulationIds.map(id => getSimulation(projectId, id)))
]);
setWorkflow(workflowResult);
} catch (error) {
setError(`Error fetching run: ${error}`);
} finally {
setLoading(false);
}
}
fetchData();
}, [projectId, runId]);
const columns = [
{
key: 'name',
label: 'SIMULATION',
render: (simulation: any) => simulation.name
},
{
key: 'scenarioId',
label: 'SCENARIO',
render: (simulation: any) => simulation.scenarioName
},
{
key: 'profileId',
label: 'PROFILE',
render: (simulation: any) => simulation.profileName
}
];
const handleDownload = async (simulationId: string) => {
try {
const result = await getSimulationResult(projectId, runId, simulationId);
if (!result) {
console.error("No result found for simulation");
return;
}
// Get simulation name from simulations array
const simulation = simulations.find(s => s._id === simulationId);
if (!simulation) {
console.error("Simulation not found");
return;
}
// Create a safe filename
const safeName = `${run?.name}_${simulation.name}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
// Create the JSON content
const content = {
run: run?.name,
simulation: simulation.name,
result: result.result,
details: result.details,
transcript: result.transcript
};
// Create and trigger download
const blob = new Blob([JSON.stringify(content, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${safeName}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download result:", error);
}
};
return <StructuredPanel
title="VIEW RUN"
tooltip="View details of this test run"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/runs`)}
>
All Runs
</ActionButton>
]}
>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !run && <div className="text-gray-600 text-center">Run not found</div>}
{!loading && run && (
<div className="flex flex-col gap-6 max-w-4xl">
{/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4">
{workflow && (
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Workflow Version</div>
<div className="font-medium dark:text-neutral-200">{workflow.name}</div>
</div>
)}
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Completed</div>
<div className="text-sm dark:text-neutral-300">
{run.completedAt ? <RelativeTime date={new Date(run.completedAt)} /> : 'Not completed'}
</div>
</div>
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Duration</div>
<div className="text-sm dark:text-neutral-300">
{run.completedAt ?
`${((new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime()) / 1000).toFixed(1)}s` :
'In Progress'}
</div>
</div>
</div>
{/* Results statistics */}
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-gray-50 dark:bg-neutral-800">
<div className="text-sm text-gray-600 dark:text-neutral-400">Total Tests</div>
<div className="text-2xl font-semibold dark:text-neutral-200">{run.aggregateResults?.total || 0}</div>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-sm text-green-600 dark:text-green-400">Passed</div>
<div className="text-2xl font-semibold text-green-700 dark:text-green-400">{run.aggregateResults?.passCount || 0}</div>
</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-sm text-red-600 dark:text-red-400">Failed</div>
<div className="text-2xl font-semibold text-red-700 dark:text-red-400">{run.aggregateResults?.failCount || 0}</div>
</div>
</div>
{/* Simulations List */}
<div>
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
<DataTable
items={simulations}
columns={columns}
projectId={projectId}
onDownload={handleDownload}
selectionMode="none"
/>
</div>
</div>
)}
</StructuredPanel>
}
function RunsList({ projectId }: { projectId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [runs, setRuns] = useState<WithStringId<z.infer<typeof TestRun>>[]>([]);
const [workflowMap, setWorkflowMap] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedRuns, setSelectedRuns] = useState<string[]>([]);
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedRuns([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedRuns(runs.map(run => run._id));
} else {
setSelectedRuns(Array.from(selection as Set<string>));
}
}
};
const handleCancel = async (runId: string) => {
try {
await cancelRun(projectId, runId);
// Update the run status locally after successful cancellation
setRuns(runs.map(run => {
if (run._id === runId) {
return {
...run,
status: 'cancelled'
};
}
return run;
}));
} catch (err) {
setError(`Failed to cancel run: ${err}`);
}
};
const handleDelete = async (runId: string) => {
try {
await deleteRun(projectId, runId);
// Refresh the runs list after deletion
const updatedRuns = await listRuns(projectId, page, pageSize);
setRuns(updatedRuns.runs);
setTotal(updatedRuns.total);
} catch (err) {
setError(`Failed to delete run: ${err}`);
}
};
useEffect(() => {
let ignore = false;
async function fetchRuns() {
setLoading(true);
setError(null);
try {
const result = await listRuns(projectId, page, pageSize);
if (!ignore) {
setRuns(result.runs);
setTotal(Math.ceil(result.total / pageSize));
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch runs: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchRuns();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
useEffect(() => {
let ignore = false;
async function resolveWorkflows() {
const workflowIds = runs.reduce((acc, run) => {
if (!acc.includes(run.workflowId)) {
acc.push(run.workflowId);
}
return acc;
}, [] as string[]);
const workflows = await Promise.all(workflowIds.map((workflowId) => fetchWorkflow(projectId, workflowId)));
if (ignore) {
return;
}
setWorkflowMap(workflows.filter((workflow) => workflow !== null).reduce((acc, workflow) => {
acc[workflow._id] = workflow;
return acc;
}, {} as Record<string, WithStringId<z.infer<typeof Workflow>>>));
}
if (error == null) {
resolveWorkflows();
}
return () => {
ignore = true;
};
}, [runs, error, projectId]);
const columns = [
{
key: 'name',
label: 'NAME',
render: (run: any) => run.name
},
{
key: 'status',
label: 'STATUS',
render: (run: any) => (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusStyles(run.status)}`}>
<div className={`w-1.5 h-1.5 rounded-full ${getStatusDotStyles(run.status)}`} />
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</div>
)
},
{
key: 'results',
label: 'RESULTS',
render: (run: any) => (
<div className="flex items-center gap-2">
<span className="text-green-600 dark:text-green-400">{run.passCount || 0} passed</span>
<span className="text-red-600 dark:text-red-400">{run.failCount || 0} failed</span>
</div>
)
},
{
key: 'createdAt',
label: 'STARTED',
render: (run: any) => isValidDate(run.startedAt) ?
<RelativeTime date={new Date(run.startedAt)} /> :
'Invalid date'
}
];
return (
<StructuredPanel
title="TEST RUNS"
tooltip="View and manage your test runs"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Test Runs</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
View and monitor your workflow test runs
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/simulations`)}
>
New Run
</Button>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Runs Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading test runs...
</div>
) : runs.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No test runs created yet</p>
</div>
) : (
<DataTable
items={runs}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onView={(id) => router.push(`/projects/${projectId}/test/runs/${id}`)}
projectId={projectId}
/>
)}
</div>
</StructuredPanel>
);
}
// Helper functions for status styling
function getStatusStyles(status: string): string {
const styles = {
pending: "bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300",
running: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
completed: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
cancelled: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
error: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
};
return styles[status as keyof typeof styles] || styles.pending;
}
function getStatusDotStyles(status: string): string {
const styles = {
pending: "bg-gray-500 dark:bg-neutral-400",
running: "bg-blue-500 dark:bg-blue-400",
completed: "bg-green-500 dark:bg-green-400",
cancelled: "bg-yellow-500 dark:bg-yellow-400",
failed: "bg-red-500 dark:bg-red-400",
error: "bg-red-500 dark:bg-red-400"
};
return styles[status as keyof typeof styles] || styles.pending;
}
export function RunsApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" = "list";
let runId: string | null = null;
if (slug.length > 0) {
selection = "view";
runId = slug[0];
}
return <>
{selection === "list" && <RunsList projectId={projectId} />}
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
</>;
}

View file

@ -1,438 +0,0 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createScenario, getScenario, listScenarios, updateScenario, deleteScenario } from "@/app/actions/testing_actions";
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon, } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { DataTable } from "./components/table";
import { isValidDate } from './utils/date';
import { ItemView } from "./components/item-view"
import { ScenarioForm } from "./components/scenario-form";
function EditScenario({
projectId,
scenarioId,
}: {
projectId: string,
scenarioId: string,
}) {
const router = useRouter();
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
async function fetchScenario() {
setError(null);
try {
const scenario = await getScenario(projectId, scenarioId);
setScenario(scenario);
} catch (error) {
setError(`Unable to fetch scenario: ${error}`);
} finally {
setLoading(false);
}
}
fetchScenario();
}, [scenarioId, projectId]);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
await updateScenario(projectId, scenarioId, { name, description });
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setError(`Unable to update scenario: ${error}`);
}
}
return <StructuredPanel
title="EDIT SCENARIO"
tooltip="Edit an existing test scenario"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading scenario...
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{!loading && scenario && (
<ScenarioForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
submitButtonText="Update Scenario"
defaultValues={{
name: scenario.name,
description: scenario.description
}}
/>
)}
</div>
</StructuredPanel>;
}
function ViewScenario({
projectId,
scenarioId,
}: {
projectId: string,
scenarioId: string,
}) {
const router = useRouter();
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
async function fetchScenario() {
const scenario = await getScenario(projectId, scenarioId);
setScenario(scenario);
setLoading(false);
}
fetchScenario();
}, [scenarioId, projectId]);
async function handleDelete() {
try {
await deleteScenario(projectId, scenarioId);
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setDeleteError(`Failed to delete scenario: ${error}`);
}
}
return (
<StructuredPanel
title="VIEW SCENARIO"
tooltip="View scenario details"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/scenarios`)}
>
All Scenarios
</ActionButton>
]}
>
<ItemView
items={[
{ label: "Name", value: scenario?.name },
{ label: "Description", value: scenario?.description },
{
label: "Created",
value: scenario?.createdAt && isValidDate(scenario.createdAt)
? <RelativeTime date={new Date(scenario.createdAt)} />
: 'Invalid date'
},
{
label: "Last Updated",
value: scenario?.lastUpdatedAt && isValidDate(scenario.lastUpdatedAt)
? <RelativeTime date={new Date(scenario.lastUpdatedAt)} />
: 'Invalid date'
}
]}
actions={
<>
<Button size="sm" variant="flat" onPress={() => router.push(`/projects/${projectId}/test/scenarios/${scenarioId}/edit`)}>Edit</Button>
<Button size="sm" color="danger" variant="flat" onPress={() => setIsDeleteModalOpen(true)}>Delete</Button>
</>
}
/>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this scenario?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button size="sm" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</StructuredPanel>
);
}
function NewScenario({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
await createScenario(projectId, { name, description });
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setError(`Unable to create scenario: ${error}`);
}
}
return <StructuredPanel
title="NEW SCENARIO"
tooltip="Create a new test scenario"
>
<div className="flex flex-col gap-6 max-w-2xl">
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
<ScenarioForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
submitButtonText="Create Scenario"
/>
</div>
</StructuredPanel>;
}
function ScenarioList({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedScenarios, setSelectedScenarios] = useState<string[]>([]);
useEffect(() => {
let ignore = false;
async function fetchScenarios() {
setLoading(true);
setError(null);
try {
const scenarios = await listScenarios(projectId, page, pageSize);
if (!ignore) {
setScenarios(scenarios.scenarios);
setTotal(Math.ceil(scenarios.total / pageSize));
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch scenarios: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
if (error == null) {
fetchScenarios();
}
return () => {
ignore = true;
};
}, [page, pageSize, error, projectId]);
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedScenarios([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedScenarios(scenarios.map(scenario => scenario._id));
} else {
setSelectedScenarios(Array.from(selection as Set<string>));
}
}
};
const handleDelete = async (scenarioId: string) => {
try {
await deleteScenario(projectId, scenarioId);
// Refresh the scenarios list after deletion
const result = await listScenarios(projectId, page, pageSize);
setScenarios(result.scenarios);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete scenario: ${err}`);
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (scenario: any) => scenario.name
},
{
key: 'description',
label: 'DESCRIPTION'
},
{
key: 'createdAt',
label: 'CREATED',
render: (scenario: any) => isValidDate(scenario.createdAt) ?
<RelativeTime date={new Date(scenario.createdAt)} /> :
'Invalid date'
}
];
return <StructuredPanel
title="SCENARIOS"
tooltip="View and manage your test scenarios"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Scenarios</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Create and manage test scenarios for your simulations
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
>
New Scenario
</Button>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Scenarios Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading scenarios...
</div>
) : scenarios.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No scenarios created yet</p>
</div>
) : (
<DataTable
items={scenarios}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/scenarios/${id}/edit`)}
projectId={projectId}
/>
)}
</div>
</StructuredPanel>;
}
export function ScenariosApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let scenarioId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[1] === "edit") {
selection = "edit";
scenarioId = slug[0];
} else {
selection = "list";
scenarioId = slug[0];
}
}
return (
<div className="h-full">
{selection === "list" && <ScenarioList projectId={projectId} />}
{selection === "new" && <NewScenario projectId={projectId} />}
{selection === "edit" && scenarioId && (
<EditScenario projectId={projectId} scenarioId={scenarioId} />
)}
</div>
);
}

View file

@ -1,595 +0,0 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile, TestScenario, TestSimulation, TestRun } from "@/app/lib/types/testing_types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { useEffect, useState, useRef } from "react";
import { createSimulation, getSimulation, listSimulations, updateSimulation, deleteSimulation, getScenario, getProfile, createRun } from "@/app/actions/testing_actions";
import { Button, Spinner, Tooltip, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PlusIcon, ArrowLeftIcon, AlertTriangleIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { ScenarioSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/scenario-selector";
import { ProfileSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/profile-selector";
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { WorkflowSelector } from "@/app/projects/[projectId]/test/[[...slug]]/components/selectors/workflow-selector";
import { DataTable } from "./components/table"
import { isValidDate } from './utils/date';
import { SimulationForm } from "./components/simulation-form";
function EditSimulation({
projectId,
simulationId,
}: {
projectId: string,
simulationId: string,
}) {
const router = useRouter();
const [simulation, setSimulation] = useState<WithStringId<z.infer<typeof TestSimulation>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [isScenarioModalOpen, setIsScenarioModalOpen] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
async function fetchSimulation() {
setError(null);
try {
const simulation = await getSimulation(projectId, simulationId);
setSimulation(simulation);
if (simulation) {
const [scenarioResult, profileResult] = await Promise.all([
getScenario(projectId, simulation.scenarioId),
simulation.profileId ? getProfile(projectId, simulation.profileId) : Promise.resolve(null),
]);
setScenario(scenarioResult);
setProfile(profileResult);
}
} catch (error) {
setError(`Unable to fetch simulation: ${error}`);
} finally {
setLoading(false);
}
}
fetchSimulation();
}, [simulationId, projectId]);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const passCriteria = formData.get("passCriteria") as string;
if (!scenario) {
throw new Error("Please select a scenario");
}
await updateSimulation(projectId, simulationId, {
name,
description,
scenarioId: scenario._id,
profileId: profile?._id || null,
passCriteria
});
router.push(`/projects/${projectId}/test/simulations`);
} catch (error) {
setError(`Unable to update simulation: ${error}`);
}
}
return <StructuredPanel
title="EDIT SIMULATION"
tooltip="Edit an existing test simulation"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading simulation...
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{!loading && simulation && (
<>
<div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Edit Simulation</h1>
<p className="text-gray-600 dark:text-neutral-400">
Define a test simulation by selecting a scenario and optionally a profile
</p>
</div>
<SimulationForm
formRef={formRef}
handleSubmit={handleSubmit}
scenario={scenario}
setScenario={setScenario}
profile={profile}
setProfile={setProfile}
isScenarioModalOpen={isScenarioModalOpen}
setIsScenarioModalOpen={setIsScenarioModalOpen}
isProfileModalOpen={isProfileModalOpen}
setIsProfileModalOpen={setIsProfileModalOpen}
projectId={projectId}
submitButtonText="Update Simulation"
defaultValues={{
name: simulation.name ?? '',
description: simulation.description ?? '',
passCriteria: simulation.passCriteria ?? ''
}}
onCancel={() => router.push(`/projects/${projectId}/test/simulations`)}
/>
<ScenarioSelector
projectId={projectId}
isOpen={isScenarioModalOpen}
onOpenChange={setIsScenarioModalOpen}
onSelect={setScenario}
/>
<ProfileSelector
projectId={projectId}
isOpen={isProfileModalOpen}
onOpenChange={setIsProfileModalOpen}
onSelect={setProfile}
/>
</>
)}
</div>
</StructuredPanel>;
}
function NewSimulation({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [scenario, setScenario] = useState<WithStringId<z.infer<typeof TestScenario>> | null>(null);
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const [isScenarioModalOpen, setIsScenarioModalOpen] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const passCriteria = formData.get("passCriteria") as string;
if (!name || !passCriteria) {
throw new Error("Name and Pass Criteria are required");
}
if (!scenario) {
throw new Error("Please select a scenario");
}
const result = await createSimulation(projectId, {
name,
description,
scenarioId: scenario._id,
profileId: profile?._id || null,
passCriteria,
});
router.push(`/projects/${projectId}/test/simulations/${result._id}`);
} catch (error) {
setError(`Unable to create simulation: ${error}`);
}
}
return <StructuredPanel
title="NEW SIMULATION"
tooltip="Create a new test simulation"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/simulations`)}
>
All Simulations
</ActionButton>
]}
>
<div className="h-full flex flex-col gap-6 max-w-2xl">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Simulation</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Define a new test simulation by selecting a scenario and optionally a profile
</p>
</div>
{error && <div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
<SimulationForm
formRef={formRef}
handleSubmit={handleSubmit}
scenario={scenario}
setScenario={setScenario}
profile={profile}
setProfile={setProfile}
isScenarioModalOpen={isScenarioModalOpen}
setIsScenarioModalOpen={setIsScenarioModalOpen}
isProfileModalOpen={isProfileModalOpen}
setIsProfileModalOpen={setIsProfileModalOpen}
projectId={projectId}
submitButtonText="Create Simulation"
onCancel={() => router.push(`/projects/${projectId}/test/simulations`)}
/>
</div>
</StructuredPanel>;
}
function SimulationList({ projectId }: { projectId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const pageSize = 10;
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [selectedSimulations, setSelectedSimulations] = useState<string[]>([]);
const [selectedWorkflow, setSelectedWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [isWorkflowSelectorOpen, setIsWorkflowSelectorOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [simulationDetails, setSimulationDetails] = useState<Record<string, {
scenario?: WithStringId<z.infer<typeof TestScenario>>,
profile?: WithStringId<z.infer<typeof TestProfile>>
}>>({});
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
useEffect(() => {
let ignore = false;
async function fetchSimulations() {
setLoading(true);
setError(null);
try {
const result = await listSimulations(projectId, page, pageSize);
if (!ignore) {
setSimulations(result.simulations);
setTotal(result.total);
// Fetch scenario and profile details for each simulation
const details: Record<string, any> = {};
await Promise.all(result.simulations.map(async (simulation) => {
const [scenarioResult, profileResult] = await Promise.all([
getScenario(projectId, simulation.scenarioId),
simulation.profileId ? getProfile(projectId, simulation.profileId) : Promise.resolve(null),
]);
if (!ignore) {
details[simulation._id] = {
scenario: scenarioResult,
profile: profileResult
};
}
}));
if (!ignore) {
setSimulationDetails(details);
}
}
} catch (error) {
if (!ignore) {
setError(`Unable to fetch simulations: ${error}`);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchSimulations();
return () => {
ignore = true;
};
}, [projectId, page, pageSize]);
const handleSelectionChange = (selection: Selection) => {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedSimulations(simulations.map(sim => sim._id));
} else {
setSelectedSimulations(Array.from(selection as Set<string>));
}
};
async function handleCreateRun() {
if (!selectedWorkflow || selectedSimulations.length === 0) {
return; // Just return without setting error
}
try {
const run = await createRun(projectId, {
workflowId: selectedWorkflow._id,
simulationIds: selectedSimulations
});
setSelectedSimulations([]);
setSelectedWorkflow(null);
router.push(`/projects/${projectId}/test/runs/${run._id}`);
} catch (err) {
setError(`Failed to create test run: ${err}`);
}
}
const handleDelete = async (simulationId: string) => {
try {
await deleteSimulation(projectId, simulationId);
// Refresh the simulations list after deletion
const result = await listSimulations(projectId, page, pageSize);
setSimulations(result.simulations);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete simulation: ${err}`);
}
};
const handleLaunchClick = () => {
if (!selectedWorkflow || selectedSimulations.length === 0) {
alert("Please select a workflow version and at least one simulation.");
} else {
handleCreateRun();
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (simulation: any) => (
<div className="flex items-center gap-2">
<span>{simulation.name}</span>
{(!simulationDetails[simulation._id]?.scenario ||
(simulation.profileId && !simulationDetails[simulation._id]?.profile)) && (
<Tooltip content="Associated scenario or profile has been deleted">
<AlertTriangleIcon
size={16}
className="text-amber-500 dark:text-amber-400"
/>
</Tooltip>
)}
</div>
)
},
{
key: 'scenarioId',
label: 'SCENARIO',
render: (simulation: any) => {
const details = simulationDetails[simulation._id];
if (!details?.scenario) {
return (
<div className="flex items-center gap-1 text-amber-500 dark:text-amber-400">
<Tooltip content="This scenario has been deleted">
<AlertTriangleIcon size={14} />
</Tooltip>
<span>Deleted</span>
</div>
);
}
return details.scenario.name;
}
},
{
key: 'profileId',
label: 'PROFILE',
render: (simulation: any) => {
const details = simulationDetails[simulation._id];
if (simulation.profileId && !details?.profile) {
return (
<div className="flex items-center gap-1 text-amber-500 dark:text-amber-400">
<Tooltip content="This profile has been deleted">
<AlertTriangleIcon size={14} />
</Tooltip>
<span>Deleted</span>
</div>
);
}
return details?.profile?.name || 'None';
}
},
{
key: 'createdAt',
label: 'CREATED',
render: (simulation: any) => isValidDate(simulation.createdAt) ?
<RelativeTime date={new Date(simulation.createdAt)} /> :
'Invalid date'
}
];
return (
<StructuredPanel
title="SIMULATIONS"
tooltip="View and manage your test simulations"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Combined Guidance and Run Creation Section */}
<div className="flex flex-col gap-4 p-6 bg-white dark:bg-neutral-950 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Create a Test Run
</h2>
<div className="flex flex-col gap-4">
{/* Step 1: Create New Simulation */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
1
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Create a New Simulation (Optional)
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
Define a new test simulation if needed
</p>
<div className="mt-3">
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/simulations/new`)}
>
New Simulation
</Button>
</div>
</div>
</div>
{/* Step 2: Select Workflow Version */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
2
</div>
<div className="flex-1">
<div className="flex items-center gap-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Select workflow version
</h3>
<Button
size="sm"
variant={selectedWorkflow ? "solid" : "flat"}
onPress={() => setIsWorkflowSelectorOpen(true)}
>
{selectedWorkflow?.name || 'Select Version'}
</Button>
</div>
</div>
</div>
{/* Step 3: Select Simulations */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
3
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Select Simulations for the Test Run
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-neutral-400">
Choose one or more simulations from the table below
</p>
</div>
</div>
{/* Step 4: Create Test Run */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium">
4
</div>
<div className="flex-1">
<div className="flex items-center gap-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Create test run
</h3>
<Tooltip
content={
!selectedWorkflow && selectedSimulations.length === 0
? "Please select a workflow version and at least one simulation"
: !selectedWorkflow
? "Please select a workflow version"
: selectedSimulations.length === 0
? "Please select at least one simulation"
: ""
}
isDisabled={Boolean(selectedWorkflow && selectedSimulations.length > 0)}
>
<Button
size="sm"
color="primary"
onPress={handleCreateRun}
className={(!selectedWorkflow || selectedSimulations.length === 0) ? "opacity-50 cursor-not-allowed" : ""}
>
Launch Test Run {selectedSimulations.length > 0 ? `(${selectedSimulations.length})` : ''}
</Button>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
{/* Error Display - Only for API/system errors */}
{error && error.startsWith('Failed to') && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{/* Simulations Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading simulations...
</div>
) : simulations.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400">No simulations created yet</p>
</div>
) : (
<DataTable
items={simulations}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/simulations/${id}/edit`)}
projectId={projectId}
/>
)}
<WorkflowSelector
projectId={projectId}
isOpen={isWorkflowSelectorOpen}
onOpenChange={setIsWorkflowSelectorOpen}
onSelect={setSelectedWorkflow}
/>
</div>
</StructuredPanel>
);
}
export function SimulationsApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let simulationId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[1] === "edit") {
selection = "edit";
simulationId = slug[0];
} else {
selection = "list";
simulationId = slug[0];
}
}
return (
<div className="h-full">
{selection === "list" && <SimulationList projectId={projectId} />}
{selection === "new" && <NewSimulation projectId={projectId} />}
{selection === "edit" && simulationId && (
<EditSimulation projectId={projectId} simulationId={simulationId} />
)}
</div>
);
}

View file

@ -1,53 +0,0 @@
'use client';
import { useRouter } from "next/navigation";
import { StructuredPanel } from "../../../../lib/components/structured-panel";
import { ListItem } from "../../../../lib/components/structured-list";
export function TestingMenu({
projectId,
app,
}: {
projectId: string;
app: "scenarios" | "simulations" | "profiles" | "runs";
}) {
const router = useRouter();
const menuItems = [
{
label: "Scenarios",
href: `/projects/${projectId}/test/scenarios`,
isSelected: app === "scenarios"
},
{
label: "Profiles",
href: `/projects/${projectId}/test/profiles`,
isSelected: app === "profiles"
},
{
label: "Simulations",
href: `/projects/${projectId}/test/simulations`,
isSelected: app === "simulations"
},
{
label: "Test Runs",
href: `/projects/${projectId}/test/runs`,
isSelected: app === "runs"
},
];
return (
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
<div className="overflow-auto flex flex-col gap-1 justify-start">
{menuItems.map((item) => (
<ListItem
key={item.label}
name={item.label}
isSelected={item.isSelected}
onClick={() => router.push(item.href)}
/>
))}
</div>
</StructuredPanel>
);
}

View file

@ -1,4 +0,0 @@
export const isValidDate = (date: any): boolean => {
const parsed = new Date(date);
return parsed instanceof Date && !isNaN(parsed.getTime());
};

View file

@ -1,20 +1,17 @@
"use client";
import { MCPServer, WithStringId } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { WithStringId } from "../../../lib/types/types";
import { DataSource } from "../../../lib/types/datasource_types";
import { z } from "zod";
import { useCallback, useEffect, useState } from "react";
import { WorkflowEditor } from "./workflow_editor";
import { WorkflowSelector } from "./workflow_selector";
import { Spinner } from "@heroui/react";
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow } from "../../../actions/workflow_actions";
import { listDataSources } from "../../../actions/datasource_actions";
import { listMcpServers } from "@/app/actions/mcp_actions";
import { collectProjectTools } from "@/app/actions/project_actions";
import { collectProjectTools, revertToLiveWorkflow } from "@/app/actions/project_actions";
import { getProjectConfig } from "@/app/actions/project_actions";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { getEligibleModels } from "@/app/actions/billing_actions";
import { ModelsResponse } from "@/app/lib/types/billing_types";
import { Project } from "@/app/lib/types/project_types";
export function App({
projectId,
@ -25,97 +22,54 @@ export function App({
useRag: boolean;
defaultModel: string;
}) {
const [selectorKey, setSelectorKey] = useState(0);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const [mode, setMode] = useState<'draft' | 'live'>('draft');
const [project, setProject] = useState<WithStringId<z.infer<typeof Project>> | null>(null);
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
const [projectTools, setProjectTools] = useState<z.infer<typeof WorkflowTool>[] | null>(null);
const [loading, setLoading] = useState(false);
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
const [mcpServerUrls, setMcpServerUrls] = useState<Array<z.infer<typeof MCPServer>>>([]);
const [toolWebhookUrl, setToolWebhookUrl] = useState<string>('');
const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*");
const handleSelect = useCallback(async (workflowId: string) => {
// choose which workflow to display
let workflow: z.infer<typeof Workflow> | undefined = project?.draftWorkflow;
if (mode == 'live') {
workflow = project?.liveWorkflow;
}
const loadData = useCallback(async () => {
setLoading(true);
const [
workflow,
publishedWorkflowId,
project,
dataSources,
mcpServers,
projectConfig,
projectTools,
eligibleModels,
] = await Promise.all([
fetchWorkflow(projectId, workflowId),
fetchPublishedWorkflowId(projectId),
listDataSources(projectId),
listMcpServers(projectId),
getProjectConfig(projectId),
listDataSources(projectId),
collectProjectTools(projectId),
getEligibleModels(),
]);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setProject(project);
setDataSources(dataSources);
setMcpServerUrls(mcpServers);
setToolWebhookUrl(projectConfig.webhookUrl ?? '');
setProjectTools(projectTools);
setEligibleModels(eligibleModels);
setLoading(false);
}, [projectId]);
function handleShowSelector() {
// clear the last workflow id from local storage
localStorage.removeItem(`lastWorkflowId_${projectId}`);
setAutoSelectIfOnlyOneWorkflow(false);
setWorkflow(null);
}
async function handleCreateNewVersion() {
setLoading(true);
const workflow = await createWorkflow(projectId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setLoading(false);
}
async function handleCloneVersion(workflowId: string) {
setLoading(true);
const workflow = await cloneWorkflow(projectId, workflowId);
const publishedWorkflowId = await fetchPublishedWorkflowId(projectId);
const dataSources = await listDataSources(projectId);
// Store the selected workflow ID in local storage
localStorage.setItem(`lastWorkflowId_${projectId}`, workflow._id);
setWorkflow(workflow);
setPublishedWorkflowId(publishedWorkflowId);
setDataSources(dataSources);
setLoading(false);
}
// whenever workflow becomes null, increment selectorKey
useEffect(() => {
if (!workflow) {
setSelectorKey(s => s + 1);
}
}, [workflow]);
// Add this useEffect for initial load
useEffect(() => {
// Check localStorage first, fall back to lastWorkflowId prop
const storedWorkflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (storedWorkflowId) {
handleSelect(storedWorkflowId);
}
}, [handleSelect, projectId]);
loadData();
}, [mode, loadData, projectId]);
function handleSetMode(mode: 'draft' | 'live') {
setMode(mode);
}
async function handleRevertToLive() {
setLoading(true);
await revertToLiveWorkflow(projectId);
loadData();
}
// if workflow is null, show the selector
// else show workflow editor
@ -124,26 +78,21 @@ export function App({
<Spinner size="sm" />
<div>Loading workflow...</div>
</div>}
{!loading && workflow == null && <WorkflowSelector
{!loading && !workflow && <div>No workflow found!</div>}
{!loading && project && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor
key={project._id}
projectId={projectId}
key={selectorKey}
handleSelect={handleSelect}
handleCreateNewVersion={handleCreateNewVersion}
autoSelectIfOnlyOneWorkflow={autoSelectIfOnlyOneWorkflow}
/>}
{!loading && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor
key={workflow._id}
isLive={mode == 'live'}
workflow={workflow}
dataSources={dataSources}
projectTools={projectTools}
publishedWorkflowId={publishedWorkflowId}
handleShowSelector={handleShowSelector}
handleCloneVersion={handleCloneVersion}
useRag={useRag}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
mcpServerUrls={project.mcpServers || []}
toolWebhookUrl={project.webhookUrl || ''}
defaultModel={defaultModel}
eligibleModels={eligibleModels}
onChangeMode={handleSetMode}
onRevertToLive={handleRevertToLive}
/>}
</>
}

View file

@ -4,6 +4,7 @@ import { USE_RAG } from "@/app/lib/feature_flags";
import { projectsCollection } from "@/app/lib/mongodb";
import { notFound } from "next/navigation";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { migrate_versioned_workflows } from "@/app/lib/migrate_versioned_workflows";
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
@ -26,6 +27,11 @@ export default async function Page(
notFound();
}
// migrate versioned workflows for this project
if (!project.draftWorkflow) {
await migrate_versioned_workflows(params.projectId);
}
return (
<App
projectId={params.projectId}

View file

@ -1,10 +0,0 @@
import { RadioIcon } from "lucide-react";
export function PublishedBadge() {
return (
<div className="bg-green-500/10 rounded-md px-2 py-1 flex items-center gap-1">
<RadioIcon size={16} className="text-green-500" />
<div className="text-green-500 text-xs font-medium uppercase">Live</div>
</div>
);
}

View file

@ -8,7 +8,7 @@ import { AgentConfig } from "../entities/agent_config";
import { ToolConfig } from "../entities/tool_config";
import { App as ChatApp } from "../playground/app";
import { z } from "zod";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
import { PromptConfig } from "../entities/prompt_config";
import { EditableField } from "../../../lib/components/editable-field";
import { RelativeTime } from "@primer/react";
@ -20,8 +20,8 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Copilot } from "../copilot/app";
import { publishWorkflow, renameWorkflow, saveWorkflow } from "../../../actions/workflow_actions";
import { PublishedBadge } from "./published_badge";
import { publishWorkflow } from "@/app/actions/project_actions";
import { saveWorkflow } from "@/app/actions/project_actions";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon } from "lucide-react";
import { EntityList } from "./entity_list";
@ -40,8 +40,7 @@ const PANEL_RATIOS = {
} as const;
interface StateItem {
workflow: WithStringId<z.infer<typeof Workflow>>;
publishedWorkflowId: string | null;
workflow: z.infer<typeof Workflow>;
publishing: boolean;
selection: {
type: "agent" | "tool" | "prompt" | "visualise";
@ -53,6 +52,7 @@ interface StateItem {
pendingChanges: boolean;
chatKey: number;
lastUpdatedAt: string;
isLive: boolean;
}
interface State {
@ -68,9 +68,6 @@ export type Action = {
} | {
type: "set_publishing";
publishing: boolean;
} | {
type: "set_published_workflow_id";
workflowId: string;
} | {
type: "add_agent";
agent: Partial<z.infer<typeof WorkflowAgent>>;
@ -159,7 +156,7 @@ function reducer(state: State, action: Action): State {
};
}
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
const isLive = state.present.isLive;
switch (action.type) {
case "undo": {
@ -184,24 +181,12 @@ function reducer(state: State, action: Action): State {
});
break;
}
case "update_workflow_name": {
newState = produce(state, draft => {
draft.present.workflow.name = action.name;
});
break;
}
case "set_publishing": {
newState = produce(state, draft => {
draft.present.publishing = action.publishing;
});
break;
}
case "set_published_workflow_id": {
newState = produce(state, draft => {
draft.present.publishedWorkflowId = action.workflowId;
});
break;
}
case "set_publish_error": {
newState = produce(state, draft => {
draft.present.publishError = action.error;
@ -586,29 +571,31 @@ export function useEntitySelection() {
}
export function WorkflowEditor({
projectId,
dataSources,
workflow,
publishedWorkflowId,
handleShowSelector,
handleCloneVersion,
useRag,
mcpServerUrls,
toolWebhookUrl,
defaultModel,
projectTools,
eligibleModels,
isLive,
onChangeMode,
onRevertToLive,
}: {
projectId: string;
dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: WithStringId<z.infer<typeof Workflow>>;
publishedWorkflowId: string | null;
handleShowSelector: () => void;
handleCloneVersion: (workflowId: string) => void;
workflow: z.infer<typeof Workflow>;
useRag: boolean;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
defaultModel: string;
projectTools: z.infer<typeof WorkflowTool>[];
eligibleModels: z.infer<typeof ModelsResponse> | "*";
isLive: boolean;
onChangeMode: (mode: 'draft' | 'live') => void;
onRevertToLive: () => void;
}) {
const [state, dispatch] = useReducer(reducer, {
@ -619,13 +606,13 @@ export function WorkflowEditor({
publishing: false,
selection: null,
workflow: workflow,
publishedWorkflowId: publishedWorkflowId,
saving: false,
publishError: null,
publishSuccess: false,
pendingChanges: false,
chatKey: 0,
lastUpdatedAt: workflow.lastUpdatedAt,
isLive,
}
});
const [chatMessages, setChatMessages] = useState<z.infer<typeof Message>[]>([]);
@ -634,17 +621,20 @@ export function WorkflowEditor({
}, []);
const saveQueue = useRef<z.infer<typeof Workflow>[]>([]);
const saving = useRef(false);
const isLive = state.present.workflow._id == state.present.publishedWorkflowId;
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [showCopilot, setShowCopilot] = useState(true);
const [copilotWidth, setCopilotWidth] = useState<number>(PANEL_RATIOS.copilot);
const [isInitialState, setIsInitialState] = useState(true);
const [showTour, setShowTour] = useState(true);
const copilotRef = useRef<{ handleUserMessage: (message: string) => void }>(null);
// Modal state for revert confirmation
const { isOpen: isRevertModalOpen, onOpen: onRevertModalOpen, onClose: onRevertModalClose } = useDisclosure();
// Load agent order from localStorage on mount
useEffect(() => {
const storedOrder = localStorage.getItem(`workflow_${workflow._id}_agent_order`);
const mode = isLive ? 'live' : 'draft';
const storedOrder = localStorage.getItem(`${mode}_workflow_${projectId}_agent_order`);
if (storedOrder) {
try {
const orderMap = JSON.parse(storedOrder);
@ -660,7 +650,7 @@ export function WorkflowEditor({
console.error("Error loading agent order:", e);
}
}
}, [workflow._id, workflow.agents]);
}, [workflow.agents, isLive, projectId]);
// Function to trigger copilot chat
const triggerCopilotChat = useCallback((message: string) => {
@ -675,12 +665,12 @@ export function WorkflowEditor({
// Auto-show copilot and increment key when prompt is present
useEffect(() => {
const prompt = localStorage.getItem(`project_prompt_${state.present.workflow.projectId}`);
const prompt = localStorage.getItem(`project_prompt_${projectId}`);
console.log('init project prompt', prompt);
if (prompt) {
setShowCopilot(true);
}
}, [state.present.workflow.projectId]);
}, [projectId]);
// Reset initial state when user interacts with copilot or opens other menus
useEffect(() => {
@ -788,32 +778,35 @@ export function WorkflowEditor({
acc[agent.name] = index;
return acc;
}, {} as Record<string, number>);
localStorage.setItem(`workflow_${workflow._id}_agent_order`, JSON.stringify(orderMap));
const mode = isLive ? 'live' : 'draft';
localStorage.setItem(`${mode}_workflow_${projectId}_agent_order`, JSON.stringify(orderMap));
dispatch({ type: "reorder_agents", agents });
}
async function handleRenameWorkflow(name: string) {
await renameWorkflow(state.present.workflow.projectId, state.present.workflow._id, name);
dispatch({ type: "update_workflow_name", name });
async function handlePublishWorkflow() {
await publishWorkflow(projectId, state.present.workflow);
onChangeMode('live');
}
async function handlePublishWorkflow() {
dispatch({ type: "set_publishing", publishing: true });
await publishWorkflow(state.present.workflow.projectId, state.present.workflow._id);
dispatch({ type: "set_publishing", publishing: false });
dispatch({ type: "set_published_workflow_id", workflowId: state.present.workflow._id });
function handleRevertToLive() {
onRevertModalOpen();
}
function handleConfirmRevert() {
onRevertToLive();
onRevertModalClose();
}
// Remove handleCopyJSON and add handleDownloadJSON
function handleDownloadJSON() {
const { _id, projectId, ...workflow } = state.present.workflow;
const workflow = state.present.workflow;
const json = JSON.stringify(workflow, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${state.present.workflow.name || 'workflow'}.json`;
a.download = 'workflow.json';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@ -831,7 +824,7 @@ export function WorkflowEditor({
if (isLive) {
return;
} else {
await saveWorkflow(state.present.workflow.projectId, state.present.workflow._id, workflowToSave);
await saveWorkflow(projectId, workflowToSave);
}
} finally {
saving.current = false;
@ -841,7 +834,7 @@ export function WorkflowEditor({
dispatch({ type: "set_saving", saving: false });
}
}
}, [isLive]);
}, [isLive, projectId]);
useEffect(() => {
if (state.present.pendingChanges && state.present.workflow) {
@ -869,28 +862,45 @@ export function WorkflowEditor({
<div className="shrink-0 flex justify-between items-center pb-6">
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100">
<WorkflowIcon size={16} />
<Tooltip content="Click to edit">
<div>
<EditableField
key={state.present.workflow._id}
value={state.present.workflow?.name || ''}
onChange={handleRenameWorkflow}
placeholder="Name this version"
className="text-sm font-semibold"
inline={true}
/>
</div>
</Tooltip>
<div className="flex items-center gap-2">
{state.present.publishing && <Spinner size="sm" />}
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
<RadioIcon size={16} />
Live
Live workflow
</div>}
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
<PenLine size={16} />
Draft
Draft workflow
</div>}
{/* Hamburger menu for workflow version switching */}
<Dropdown>
<DropdownTrigger>
<button
className="p-1.5 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Workflow version menu"
type="button"
>
<HamburgerIcon size={16} />
</button>
</DropdownTrigger>
<DropdownMenu aria-label="Workflow version options">
<DropdownItem
key="switch-version"
onClick={() => onChangeMode(isLive ? 'draft' : 'live')}
>
{isLive ? "View Draft workflow" : "View Live workflow"}
</DropdownItem>
{!isLive ? (
<DropdownItem
key="revert-to-live"
onClick={handleRevertToLive}
className="text-red-600 dark:text-red-400"
>
Revert to Live workflow
</DropdownItem>
) : null}
</DropdownMenu>
</Dropdown>
{/* Download JSON icon button, with tooltip, to the left of the menu */}
<Tooltip content="Download Assistant JSON">
<button
@ -902,47 +912,6 @@ export function WorkflowEditor({
<DownloadIcon size={20} />
</button>
</Tooltip>
<Dropdown>
<DropdownTrigger>
<div>
<Tooltip content="Version Menu">
<button className="p-1.5 text-gray-500 hover:text-gray-800 transition-colors">
<HamburgerIcon size={20} />
</button>
</Tooltip>
</div>
</DropdownTrigger>
<DropdownMenu
disabledKeys={[
...(state.present.pendingChanges ? ['switch', 'clone'] : []),
...(isLive ? ['mcp'] : []),
]}
onAction={(key) => {
if (key === 'switch') {
handleShowSelector();
}
if (key === 'clone') {
handleCloneVersion(state.present.workflow._id);
}
}}
>
<DropdownItem
key="switch"
startContent={<div className="text-gray-500"><BackIcon size={16} /></div>}
className="gap-x-2"
>
View versions
</DropdownItem>
<DropdownItem
key="clone"
startContent={<div className="text-gray-500"><Layers2Icon size={16} /></div>}
className="gap-x-2"
>
Clone this version
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
{showCopySuccess && <div className="flex items-center gap-2">
@ -954,15 +923,6 @@ export function WorkflowEditor({
<AlertTriangle size={16} />
This version is locked. You cannot make changes. Changes applied through copilot will<b>not</b>be reflected.
</div>
<Button
variant="solid"
size="md"
onPress={() => handleCloneVersion(state.present.workflow._id)}
className="gap-2 px-4 bg-amber-600 hover:bg-amber-700 text-white font-semibold text-sm"
startContent={<Layers2Icon size={16} />}
>
Clone this version
</Button>
<Button
variant="solid"
size="md"
@ -1041,7 +1001,7 @@ export function WorkflowEditor({
onDeleteTool={handleDeleteTool}
onDeletePrompt={handleDeletePrompt}
onShowVisualise={handleShowVisualise}
projectId={state.present.workflow.projectId}
projectId={projectId}
onReorderAgents={handleReorderAgents}
/>
</div>
@ -1055,7 +1015,7 @@ export function WorkflowEditor({
<ChatApp
key={'' + state.present.chatKey}
hidden={state.present.selection !== null}
projectId={state.present.workflow.projectId}
projectId={projectId}
workflow={state.present.workflow}
messageSubscriber={updateChatMessages}
mcpServerUrls={mcpServerUrls}
@ -1067,7 +1027,7 @@ export function WorkflowEditor({
/>
{state.present.selection?.type === "agent" && <AgentConfig
key={`agent-${state.present.workflow.agents.findIndex(agent => agent.name === state.present.selection!.name)}`}
projectId={state.present.workflow.projectId}
projectId={projectId}
workflow={state.present.workflow}
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
@ -1144,7 +1104,7 @@ export function WorkflowEditor({
>
<Copilot
ref={copilotRef}
projectId={state.present.workflow.projectId}
projectId={projectId}
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
@ -1169,10 +1129,32 @@ export function WorkflowEditor({
</ResizablePanelGroup>
{USE_PRODUCT_TOUR && showTour && (
<ProductTour
projectId={state.present.workflow.projectId}
projectId={projectId}
onComplete={() => setShowTour(false)}
/>
)}
{/* Revert to Live Confirmation Modal */}
<Modal isOpen={isRevertModalOpen} onClose={onRevertModalClose}>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
Revert to Live Workflow
</ModalHeader>
<ModalBody>
<p>
Are you sure you want to revert to the live workflow? This will discard all your current draft changes and switch back to the live version.
</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onRevertModalClose}>
Cancel
</Button>
<Button color="danger" onPress={handleConfirmRevert}>
Revert to Live
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</EntitySelectionContext.Provider>
);

View file

@ -1,168 +0,0 @@
"use client";
import { WithStringId } from "../../../lib/types/types";
import { Workflow } from "../../../lib/types/workflow_types";
import { z } from "zod";
import { useEffect, useState, useCallback } from "react";
import { PublishedBadge } from "./published_badge";
import { RelativeTime } from "@primer/react";
import { listWorkflows } from "../../../actions/workflow_actions";
import { Button, Divider, Pagination } from "@heroui/react";
import { WorkflowIcon } from "../../../lib/components/icons";
import { PlusIcon } from "lucide-react";
const pageSize = 5;
function WorkflowCard({
workflow,
live = false,
handleSelect,
}: {
workflow: WithStringId<z.infer<typeof Workflow>>;
live?: boolean;
handleSelect: (workflowId: string) => void;
}) {
return <button className="flex items-center gap-2 p-2 rounded hover:bg-gray-100 cursor-pointer" onClick={() => handleSelect(workflow._id)}>
<div className="flex flex-col gap-1 items-start">
<div className="flex items-center gap-1">
<WorkflowIcon />
<div className="text-black truncate">{workflow.name || 'Unnamed workflow'}</div>
{live && <PublishedBadge />}
</div>
<div className="text-xs text-gray-400">
updated <RelativeTime date={new Date(workflow.lastUpdatedAt)} />
</div>
</div>
</button>;
}
export function WorkflowSelector({
projectId,
handleSelect,
handleCreateNewVersion,
autoSelectIfOnlyOneWorkflow,
}: {
projectId: string;
handleSelect: (workflowId: string) => void;
handleCreateNewVersion: () => void;
autoSelectIfOnlyOneWorkflow: boolean;
}) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workflows, setWorkflows] = useState<(WithStringId<z.infer<typeof Workflow>>)[]>([]);
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [retryCount, setRetryCount] = useState(0);
function handlePageChange(page: number) {
setCurrentPage(page);
setWorkflows([]);
}
function handleRetry() {
setRetryCount(retryCount + 1);
}
useEffect(() => {
let ignore = false;
async function fetchWorkflows() {
setError(null);
setLoading(true);
try {
const { workflows, total, publishedWorkflowId } = await listWorkflows(projectId, currentPage, pageSize);
if (ignore) {
console.log('ignoring', currentPage);
return;
}
setWorkflows(workflows);
setTotalPages(Math.ceil(total / pageSize));
setPublishedWorkflowId(publishedWorkflowId);
setError(null);
if (autoSelectIfOnlyOneWorkflow && workflows.length === 1) {
handleSelect(workflows[0]._id);
}
} catch (e) {
setError('Failed to load workflows');
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchWorkflows();
return () => {
ignore = true;
}
}, [projectId, currentPage, retryCount, autoSelectIfOnlyOneWorkflow, handleSelect]);
return <div className="flex flex-col gap-2 max-w-[768px] mx-auto w-full border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 justify-between">
<div className="text-lg">Select a workflow version</div>
<Button
color="primary"
startContent={<PlusIcon size={16} />}
onPress={handleCreateNewVersion}
>
Create new version
</Button>
</div>
<Divider />
{loading && <div className="flex flex-col gap-2">
{[...Array(pageSize)].map((_, i) => {
const widths = ['w-32', 'w-40', 'w-48', 'w-56'];
const randomWidth = widths[Math.floor(Math.random() * widths.length)];
return (
<div
key={i}
className="flex items-center justify-between gap-2 p-2 rounded"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className={`h-5 ${randomWidth} bg-gray-200 rounded animate-pulse`}></div>
</div>
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
);
})}
</div>}
{error && <div className="flex flex-col items-center gap-2 text-red-600">
<div>{error}</div>
<button
onClick={handleRetry}
className="px-4 py-2 text-sm bg-red-100 hover:bg-red-200 rounded"
>
Retry
</button>
</div>}
{!loading && !error && workflows.length == 0 && <div className="flex flex-col items-center gap-2">
<div className="text-sm text-gray-500">No versions found. Create a new version to get started.</div>
</div>}
{!loading && !error && workflows.length > 0 && <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{workflows.map((workflow) => (
<WorkflowCard
key={workflow._id}
workflow={workflow}
live={publishedWorkflowId == workflow._id}
handleSelect={handleSelect}
/>
))}
</div>
</div>}
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<Pagination
total={totalPages}
page={currentPage}
onChange={handlePageChange}
/>
</div>
)}
</div>
}

View file

@ -19,7 +19,7 @@ import {
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { useTheme } from "@/app/providers/theme-provider";
import { USE_TESTING_FEATURE, USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
import { USE_PRODUCT_TOUR } from '@/app/lib/feature_flags';
import { useHelpModal } from "@/app/providers/help-modal-provider";
interface SidebarProps {
@ -63,12 +63,6 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
icon: WorkflowIcon,
requiresProject: true
},
...(USE_TESTING_FEATURE ? [{
href: 'test',
label: 'Test',
icon: PlayIcon,
requiresProject: true
}] : []),
...(useRag ? [{
href: 'sources',
label: 'RAG',

View file

@ -248,7 +248,7 @@ export function CreateProject({ defaultName, onOpenProjectPane, isProjectPaneOpe
let parsed;
try {
const json = JSON.parse(importJson);
parsed = Workflow.omit({ projectId: true }).safeParse(json);
parsed = Workflow.safeParse(json);
if (!parsed.success) {
setImportError('Invalid workflow JSON: ' + JSON.stringify(parsed.error.issues));
setImportModalOpen(true);