Merge pull request #287 from rowboatlabs/dev

launch rowboatx
This commit is contained in:
Ramnique Singh 2025-11-18 22:43:15 +05:30 committed by GitHub
commit c5bf273bc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 7865 additions and 525 deletions

View file

@ -1,6 +1,6 @@
![ui](/assets/banner.png) ![ui](/assets/banner.png)
<h2 align="center">AI that builds and manages your agent swarms</h2> <h2 align="center">RowboatX - CLI Tool for Background Agents</h2>
<h5 align="center"> <h5 align="center">
<p align="center" style="display: flex; justify-content: center; gap: 20px; align-items: center;"> <p align="center" style="display: flex; justify-content: center; gap: 20px; align-items: center;">
@ -35,51 +35,59 @@
</h5> </h5>
<p align="center">
⚡ Build agent swarms instantly with natural language | 🔌 Connect tools with one-click integrations | 📂 Power with knowledge by adding documents for RAG | 🔄 Automate workflows by setting up triggers and actions | 🚀 Deploy anywhere via API or SDK<br><br>
☁️ Prefer a hosted version? Use our <b><a href="https://rowboatlabs.com">cloud</a></b> to starting building agents right away!
</p>
- ✨ **Create background agents with full shell access**
- E.g. "Generate a NotebookLM-style podcast from my saved articles every morning"
- 🔧 **Connect any MCP server to add capabilities**
- Add MCP servers and RowboatX handles the integration
- 🎯 **Let RowboatX control and monitor your background agents**
- Easily inspect state on the filesystem
Inspired by Claude Code, RowboatX brings the same shell-native power to background automations.
## Quick start ## Quick start
1. Set your OpenAI key 1. Set your LLM API key. Supports OpenAI, Anthropic, Gemini, OpenRouter, LiteLLM, Ollama, and more.
```bash ```bash
export OPENAI_API_KEY=your-openai-api-key export OPENAI_API_KEY=your-openai-api-key
``` ```
2. Clone the repository and start Rowboat (requires Docker) 2. Install RowboatX
```bash ```bash
git clone git@github.com:rowboatlabs/rowboat.git npx @rowboatlabs/rowboatx
cd rowboat
./start.sh
``` ```
3. Access the app at [http://localhost:3000](http://localhost:3000).
To add tools, RAG, more LLMs, and triggers checkout the [Advanced](#advanced) section below.
## Demos ## Demos
#### Meeting-prep assistant #### Meeting-prep assistant
Chat with the copilot to build a meeting-prep workflow, then add a calendar invite as a trigger. Watch the full demo [here](https://youtu.be/KZTP4xZM2DY). Chat with the copilot to build a meeting-prep workflow, then add a calendar invite as a trigger. Watch the full demo [here](https://youtu.be/KZTP4xZM2DY).
[![meeting-prep](https://github.com/user-attachments/assets/27755ef5-6549-476f-b9c0-50bef8770384)](https://youtu.be/KZTP4xZM2DY) [![meeting-prep](https://github.com/user-attachments/assets/27755ef5-6549-476f-b9c0-50bef8770384)](https://youtu.be/KZTP4xZM2DY)
#### Customer support assistant ## Examples
Chat with the copilot to build a customer support assistant, then connect your MCP server, and data for RAG. Watch the full demo [here](https://youtu.be/Xfo-OfgOl8w). ### Add and Manage MCP servers
[![output](https://github.com/user-attachments/assets/97485fd7-64c3-4d60-a627-f756a89dee64)](https://youtu.be/Xfo-OfgOl8w) `$ rowboatx`
- Add MCP: 'Add this MCP server config: \<config\> '
- Explore tools: 'What tools are there in \<server-name\> '
#### Personal assistant ### Create background agents
Chat with the copilot to build a personal assistant. Watch the full demo [here](https://youtu.be/6r7P4Vlcn2g). `$ rowboatx`
[![personal-assistant](https://github.com/user-attachments/assets/0f1c0ffd-23ba-4b49-8bfb-ec7a846f1332)](https://youtu.be/6r7P4Vlcn2g) - 'Create agent to do X.'
- '... Attach the correct tools from \<mcp-server-name\> to the agent'
- '... Allow the agent to run shell commands including ffmpeg'
## Advanced ### Schedule and monitor agents
1. Native RAG Support: Enable file uploads and URL scraping with Rowboat's built-in RAG capabilities see [RAG Guide](https://docs.rowboatlabs.com/docs/using-rowboat/rag). `$ rowboatx`
- 'Make agent \<background-agent-name\> run every day at 10 AM'
- 'What agents do I have scheduled to run and at what times'
- 'When was \<background-agent-name\> last run'
- 'Are any agents waiting for my input or confirmation'
2. Custom LLM Providers: Use any LLM provider, including aggregators like OpenRouter and LiteLLM - see [Using more LLM providers](https://docs.rowboatlabs.com/docs/using-rowboat/customise/custom-llms). ### Run background agents manually
``` bash
rowboatx --agent=<agent-name> --input="xyz" --no-interactive=true
```
```bash
rowboatx --agent=<agent-name> <run_id> # resume from a previous run
```
## Rowboat Classic UI
3. Tools & Triggers: Add tools and event triggers (e.g., Gmail, Slack) for automation see [Tools](https://docs.rowboatlabs.com/docs/using-rowboat/tools) & [Triggers](https://docs.rowboatlabs.com/docs/using-rowboat/triggers). To use Rowboat Classic UI (not RowboatX), refer to [Classic](https://docs.rowboatlabs.com/).
4. API & SDK: Integrate Rowboat agents directly into your app see [API](https://docs.rowboatlabs.com/docs/api-sdk/using_the_api) & [SDK](https://docs.rowboatlabs.com/docs/api-sdk/using_the_sdk) docs.
##
Refer to [Docs](https://docs.rowboatlabs.com/) to learn how to start building agents with Rowboat.

2
apps/cli/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

55
apps/cli/bin/app.js Executable file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { app } from '../dist/app.js';
yargs(hideBin(process.argv))
.command(
"$0",
"Run rowboatx",
(y) => y
.option("agent", {
type: "string",
description: "The agent to run",
default: "copilot",
})
.option("run_id", {
type: "string",
description: "Continue an existing run",
})
.option("input", {
type: "string",
description: "The input to the agent",
})
.option("no-interactive", {
type: "boolean",
description: "Do not interact with the user",
default: false,
}),
(argv) => {
app({
agent: argv.agent,
runId: argv.run_id,
input: argv.input,
noInteractive: argv.noInteractive,
});
}
)
.command(
"update-state <agent> <run_id>",
"Update state for a run",
(y) => y
.positional("agent", {
type: "string",
description: "The agent to run",
})
.positional("run_id", {
type: "string",
description: "The run id to update",
}),
(argv) => {
updateState(argv.agent, argv.run_id);
}
)
.parse();

View file

@ -0,0 +1,128 @@
{
"name": "podcast",
"description": "A workflow to create a podcast",
"steps": [
{
"type": "agent",
"id": "arxiv-feed-reader"
},
{
"type": "agent",
"id": "summarise-a-few"
},
{
"type": "agent",
"id": "podcast_transcript_agent"
},
{
"type": "agent",
"id": "elevenlabs_audio_gen"
}
]
}
{
"name": "summariser_workflow",
"description": "A workflow to summarise an arxiv paper",
"steps": [
{
"type": "agent",
"id": "summariser_agent"
}
]
}
{
"name": "summariser_agent",
"description": "An agent that will summarise an arxiv paper",
"model": "gpt-4.1",
"instructions": "Your job is to download and summarise an arxiv paper. Use a command like this to do it\n\n curl -L -o paper.pdf https://arxiv.org/pdf/2511.02997 (use the url that the user provides you). Important, just put out the GIST of the paper in two lines. Dont ask a human for inputs - do what you think is best.",
"tools": {
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
}
{
"name": "arxiv-feed-reader",
"description": "A feed reader for the arXiv",
"model": "gpt-4.1",
"instructions": "Your job is to extract the latest papers from the arXiv feed and summarise them. Use an example curl command like the following to get this done:\n\n! curl -s https://rss.arxiv.org/rss/cs.AI \\\n| yq -p=xml -o=json \\\n| jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"' \n\nThis will give you a list of papers that contain the word \"agent\" in the title. You can then summarise these papers using the summariser agent.",
"tools": {
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
}
{
"name": "summarise-a-few",
"description": "An agent that will summarise a few arxiv papers",
"model": "gpt-4.1",
"instructions": "Your job is to pick 2 interesting papers and related papers on the same topic, and then summarise each of them inidivually using the right tool calls. Make sure to pass in the URL of the paper to the summaurse tool. Don't ask for human input.",
"tools": {
"summariser": {
"type": "agent",
"name": "summariser_workflow"
}
}
}
{
"name": "podcast_transcript_agent",
"description": "An agent that will generate a transcript of a podcast",
"model": "gpt-4.1",
"instructions": "You job is to create a NotebookLM style 1 minute podcast between 2 speakers John and Chloe. Each line should be a new speaker. The podcast should be about the contents of the two papers (that were selected). You can use [sighs], [inhales then exhales], [chuckles], [laughs], [clears throat], [coughs], [sniffs], [pauses] etc. to make the podcast more natural."
}
{
"name": "elevenlabs_audio_gen",
"description": "An agent that will generate an audio file from a text",
"model": "gpt-4.1",
"instructions": "Your job is to take the mutli speaker transcript and generate an audio file from it. Use the elevenlabs text to speech tool to do this. For each speaker turn, you should generate an audio file and then combine them all into a single audio file. Use the voice_name 'Liam' for John and 'Cassidy' for Chloe. Make sure to remove the speaker names from the text before generating the audio files. Use the bash tool to look for the generated audio files and also combine the audio files into a single final podcast audio file. Use 'eleven_v3' for the model_id.",
"tools": {
"text_to_speech": {
"type": "mcp",
"name": "text_to_speech",
"description": "Generate an audio file from a text",
"mcpServerName": "elevenLabs",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to generate an audio file from"
},
"voice_name": {
"type": "string",
"description": "The voice name to use for the audio file"
},
"model_id": {
"type": "string",
"description": "The model id to use for the audio file"
}
}
}
},
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
}
{
"mcpServers": {
"elevenLabs": {
"command": "uvx",
"args": ["elevenlabs-mcp"],
"env": {
"ELEVENLABS_API_KEY": "sk_42ee2a0a19266552c18b0920b593e22f0185d4b1435b65ed"
}
}
}
}

1671
apps/cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
apps/cli/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "@rowboatlabs/rowboatx",
"version": "0.6.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rm -rf dist && tsc",
"copilot": "npm run build && node dist/x.js"
},
"files": [
"dist",
"bin"
],
"bin": {
"rowboatx": "bin/app.js"
},
"keywords": [],
"author": "Rowboat Labs",
"license": "Apache-2.0",
"description": "",
"devDependencies": {
"@types/node": "^24.9.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/google": "^2.0.25",
"@ai-sdk/openai": "^2.0.53",
"@modelcontextprotocol/sdk": "^1.20.2",
"ai": "^5.0.78",
"json-schema-to-zod": "^2.6.1",
"nanoid": "^5.1.6",
"yargs": "^18.0.0",
"zod": "^4.1.12"
}
}

183
apps/cli/src/app.ts Normal file
View file

@ -0,0 +1,183 @@
import { AgentState, streamAgent } from "./application/lib/agent.js";
import { StreamRenderer } from "./application/lib/stream-renderer.js";
import { stdin as input, stdout as output } from "node:process";
import fs from "fs";
import path from "path";
import { WorkDir } from "./application/config/config.js";
import { RunEvent } from "./application/entities/run-events.js";
import { createInterface, Interface } from "node:readline/promises";
import { ToolCallPart } from "./application/entities/message.js";
import { z } from "zod";
export async function updateState(agent: string, runId: string) {
const state = new AgentState(agent, runId);
// If running in a TTY, read run events from stdin line-by-line
if (!input.isTTY) {
return;
}
const rl = createInterface({ input, crlfDelay: Infinity });
try {
for await (const line of rl) {
if (line.trim() === "") {
continue;
}
const event = RunEvent.parse(JSON.parse(line));
state.ingestAndLog(event);
}
} finally {
rl.close();
}
}
export async function app(opts: {
agent: string;
runId?: string;
input?: string;
noInteractive?: boolean;
}) {
const renderer = new StreamRenderer();
const state = new AgentState(opts.agent, opts.runId);
// load existing and assemble state if required
let runId = opts.runId;
if (runId) {
console.error("loading run", runId);
let stream: fs.ReadStream | null = null;
let rl: Interface | null = null;
try {
const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
stream = fs.createReadStream(logFile, { encoding: "utf8" });
rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
if (line.trim() === "") {
continue;
}
const parsed = JSON.parse(line);
const event = RunEvent.parse(parsed);
state.ingest(event);
}
} finally {
stream?.close();
}
}
let rl: Interface | null = null;
if (!opts.noInteractive) {
rl = createInterface({ input, output });
}
let inputConsumed = false;
try {
while (true) {
// ask for pending tool permissions
for (const perm of Object.values(state.getPendingPermissions())) {
if (opts.noInteractive) {
return;
}
const response = await getToolCallPermission(perm.toolCall, rl!);
state.ingestAndLog({
type: "tool-permission-response",
response,
toolCallId: perm.toolCall.toolCallId,
subflow: perm.subflow,
});
}
// ask for pending human input
for (const ask of Object.values(state.getPendingAskHumans())) {
if (opts.noInteractive) {
return;
}
const response = await getAskHumanResponse(ask.query, rl!);
state.ingestAndLog({
type: "ask-human-response",
response,
toolCallId: ask.toolCallId,
subflow: ask.subflow,
});
}
// run one turn
for await (const event of streamAgent(state)) {
renderer.render(event);
if (event?.type === "error") {
process.exitCode = 1;
}
}
// if nothing pending, get user input
if (state.getPendingPermissions().length === 0 && state.getPendingAskHumans().length === 0) {
if (opts.input && !inputConsumed) {
state.ingestAndLog({
type: "message",
message: {
role: "user",
content: opts.input,
},
subflow: [],
});
inputConsumed = true;
continue;
}
if (opts.noInteractive) {
return;
}
const response = await getUserInput(rl!);
state.ingestAndLog({
type: "message",
message: {
role: "user",
content: response,
},
subflow: [],
});
}
}
} finally {
rl?.close();
}
}
async function getToolCallPermission(
call: z.infer<typeof ToolCallPart>,
rl: Interface,
): Promise<"approve" | "deny"> {
const question = `Do you want to allow running the following tool: ${call.toolName}?:
Tool name: ${call.toolName}
Tool arguments: ${JSON.stringify(call.arguments)}
Choices: y/n/a/d:
- y: approve
- n: deny
`;
const input = await rl.question(question);
if (input.toLowerCase() === "y") return "approve";
if (input.toLowerCase() === "n") return "deny";
return "deny";
}
async function getAskHumanResponse(
query: string,
rl: Interface,
): Promise<string> {
const input = await rl.question(`The agent is asking for your help with the following query:
Question: ${query}
Please respond to the question.
`);
return input;
}
async function getUserInput(
rl: Interface,
): Promise<string> {
const input = await rl.question("You: ");
if (["quit", "exit", "q"].includes(input.toLowerCase().trim())) {
console.error("Bye!");
process.exit(0);
}
return input;
}

View file

@ -0,0 +1,20 @@
import { Agent, ToolAttachment } from "../entities/agent.js";
import z from "zod";
import { CopilotInstructions } from "./instructions.js";
import { BuiltinTools } from "../lib/builtin-tools.js";
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const [name, tool] of Object.entries(BuiltinTools)) {
tools[name] = {
type: "builtin",
name,
};
}
export const CopilotAgent: z.infer<typeof Agent> = {
name: "rowboatx",
description: "Rowboatx copilot",
instructions: CopilotInstructions,
model: "gpt-5.1",
tools,
}

View file

@ -0,0 +1,44 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../config/config.js";
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}
Use the catalog below to decide which skills to load for each user request. Before acting:
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
- Apply the instructions from every loaded skill while working on the request.
${skillCatalog}
Always consult this catalog first so you load the right skills before taking action.
# Communication & Execution Style
## Communication principles
- Be concise and direct. Avoid verbose explanations unless the user asks for details.
- Only show JSON output when explicitly requested by the user. Otherwise, summarize results in plain language.
- Break complex efforts into clear, sequential steps the user can follow.
- Explain reasoning briefly as you work, and confirm outcomes before moving on.
- Be proactive about understanding missing context; ask clarifying questions when needed.
- Summarize completed work and suggest logical next steps at the end of a task.
- Always ask for confirmation before taking destructive actions.
## Execution reminders
- Explore existing files and structure before creating new assets.
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
- Keep user data safedouble-check before editing or deleting important resources.
## Builtin Tools vs Shell Commands
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries:
- \`deleteFile\`, \`createFile\`, \`updateFile\`, \`readFile\` - File operations
- \`listFiles\`, \`exploreDirectory\` - Directory exploration
- \`analyzeAgent\` - Agent analysis
- \`listMcpServers\`, \`listMcpTools\` - MCP server management
- \`loadSkill\` - Skill loading
These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`.
**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`deleteFile\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`createFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools.
`;

View file

@ -0,0 +1,180 @@
export const skill = String.raw`
# Builtin Tools Reference
Load this skill when creating or modifying agents that need access to Rowboat's builtin tools (shell execution, file operations, etc.).
## Available Builtin Tools
Agents can use builtin tools by declaring them in the \`"tools"\` object with \`"type": "builtin"\` and the appropriate \`"name"\`.
### executeCommand
**The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output.
**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
**Agent tool declaration:**
\`\`\`json
"tools": {
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
\`\`\`
**What it can do:**
- Run package managers (npm, pip, apt, brew, cargo, go get, etc.)
- Git operations (clone, commit, push, pull, status, diff, log, etc.)
- System operations (ps, top, df, du, find, grep, kill, etc.)
- Build and compilation (make, cargo build, go build, npm run build, etc.)
- Network operations (curl, wget, ping, ssh, netstat, etc.)
- Text processing (awk, sed, grep, jq, yq, cut, sort, uniq, etc.)
- Database operations (psql, mysql, mongo, redis-cli, etc.)
- Container operations (docker, kubectl, podman, etc.)
- Testing and debugging (pytest, jest, cargo test, etc.)
- File operations (cat, head, tail, wc, diff, patch, etc.)
- Any CLI tool or script execution
**Agent instruction examples:**
- "Use the bash tool to run git commands for version control operations"
- "Execute curl commands using the bash tool to fetch data from APIs"
- "Use bash to run 'npm install' and 'npm test' commands"
- "Run Python scripts using the bash tool with 'python script.py'"
- "Use bash to execute 'docker ps' and inspect container status"
- "Run database queries using 'psql' or 'mysql' commands via bash"
- "Use bash to execute system monitoring commands like 'top' or 'ps aux'"
**Pro tips for agent instructions:**
- Commands can be chained with && for sequential execution
- Use pipes (|) to combine Unix tools (e.g., "cat file.txt | grep pattern | wc -l")
- Redirect output with > or >> when needed
- Full bash shell features are available (variables, loops, conditionals, etc.)
- Tools like jq, yq, awk, sed can parse and transform data
**Example agent with executeCommand:**
\`\`\`json
{
"name": "arxiv-feed-reader",
"description": "A feed reader for the arXiv",
"model": "gpt-5.1",
"instructions": "Extract latest papers from the arXiv feed and summarize them. Use curl to fetch the RSS feed, then parse it with yq and jq:\n\ncurl -s https://rss.arxiv.org/rss/cs.AI | yq -p=xml -o=json | jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"'\n\nThis will give you papers containing 'agent' in the title.",
"tools": {
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
}
\`\`\`
**Another example - System monitoring agent:**
\`\`\`json
{
"name": "system-monitor",
"description": "Monitor system resources and processes",
"model": "gpt-5.1",
"instructions": "Monitor system resources using bash commands. Use 'df -h' for disk usage, 'free -h' for memory, 'top -bn1' for processes, 'ps aux' for process list. Parse the output and report any issues.",
"tools": {
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
}
\`\`\`
**Another example - Git automation agent:**
\`\`\`json
{
"name": "git-helper",
"description": "Automate git operations",
"model": "gpt-5.1",
"instructions": "Help with git operations. Use commands like 'git status', 'git log --oneline -10', 'git diff', 'git branch -a' to inspect the repository. Can also run 'git add', 'git commit', 'git push' when instructed.",
"tools": {
"bash": {
"type": "builtin",
"name": "executeCommand"
}
}
}
\`\`\`
## Agent-to-Agent Calling
Agents can call other agents as tools to create complex multi-step workflows. This is the core mechanism for building multi-agent systems in the CLI.
**Tool declaration:**
\`\`\`json
"tools": {
"summariser": {
"type": "agent",
"name": "summariser_agent"
}
}
\`\`\`
**When to use:**
- Breaking complex tasks into specialized sub-agents
- Creating reusable agent components
- Orchestrating multi-step workflows
- Delegating specialized tasks (e.g., summarization, data processing, audio generation)
**How it works:**
- The agent calls the tool like any other tool
- The target agent receives the input and processes it
- Results are returned as tool output
- The calling agent can then continue processing or delegate further
**Example - Agent that delegates to a summarizer:**
\`\`\`json
{
"name": "paper_analyzer",
"model": "gpt-5.1",
"instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the summariser. Don't ask for human input.",
"tools": {
"summariser": {
"type": "agent",
"name": "summariser_agent"
}
}
}
\`\`\`
**Tips for agent chaining:**
- Make instructions explicit about when to call other agents
- Pass clear, structured data between agents
- Add "Don't ask for human input" for autonomous workflows
- Keep each agent focused on a single responsibility
## Additional Builtin Tools
While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`.
## Best Practices
1. **Give agents clear examples** in their instructions showing exact bash commands to run
2. **Explain output parsing** - show how to use jq, yq, grep, awk to extract data
3. **Chain commands efficiently** - use && for sequences, | for pipes
4. **Handle errors** - remind agents to check exit codes and stderr
5. **Be specific** - provide example commands rather than generic descriptions
6. **Security** - remind agents to validate inputs and avoid dangerous operations
## When to Use Builtin Tools vs MCP Tools vs Agent Tools
- **Use builtin executeCommand** when you need: CLI tools, system operations, data processing, git operations, any shell command
- **Use MCP tools** when you need: Web scraping (firecrawl), text-to-speech (elevenlabs), specialized APIs, external service integrations
- **Use agent tools (\`"type": "agent"\`)** when you need: Complex multi-step logic, task delegation, specialized processing that benefits from LLM reasoning
Many tasks can be accomplished with just \`executeCommand\` and common Unix tools - it's incredibly powerful!
## Key Insight: Multi-Agent Workflows
In the CLI, multi-agent workflows are built by:
1. Creating specialized agents for specific tasks (in \`agents/\` directory)
2. Creating an orchestrator agent that has other agents in its \`tools\`
3. Running the orchestrator with \`rowboatx --agent orchestrator_name\`
There are no separate "workflow" files - everything is an agent!
`;
export default skill;

View file

@ -0,0 +1,24 @@
export const skill = String.raw`
# Deletion Guardrails
Load this skill when a user asks to delete agents or workflows so you follow the required confirmation steps.
## Workflow deletion protocol
1. Read the workflow file to identify every agent it references.
2. Report those agents to the user and ask whether they should be deleted too.
3. Wait for explicit confirmation before deleting anything.
4. Only remove the workflow and/or agents the user authorizes.
## Agent deletion protocol
1. Inspect the agent file to discover which workflows reference it.
2. List those workflows to the user and ask whether they should be updated or deleted.
3. Pause for confirmation before modifying workflows or removing the agent.
4. Perform only the deletions the user approves.
## Safety checklist
- Never delete cascaded resources automatically.
- Keep a clear audit trail in your responses describing what was removed.
- If the users instructions are ambiguous, ask clarifying questions before taking action.
`;
export default skill;

View file

@ -0,0 +1,151 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import builtinToolsSkill from "./builtin-tools/skill.js";
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
const CURRENT_FILE = fileURLToPath(import.meta.url);
const CURRENT_DIR = path.dirname(CURRENT_FILE);
const CATALOG_PREFIX = "src/application/assistant/skills";
type SkillDefinition = {
id: string;
title: string;
folder: string;
summary: string;
content: string;
};
type ResolvedSkill = {
id: string;
catalogPath: string;
content: string;
};
const definitions: SkillDefinition[] = [
{
id: "workflow-authoring",
title: "Workflow Authoring",
folder: "workflow-authoring",
summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.",
content: workflowAuthoringSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",
folder: "builtin-tools",
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
content: builtinToolsSkill,
},
{
id: "mcp-integration",
title: "MCP Integration Guidance",
folder: "mcp-integration",
summary: "Listing MCP servers/tools and embedding their schemas in agent definitions.",
content: mcpIntegrationSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
folder: "deletion-guardrails",
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
content: deletionGuardrailsSkill,
},
{
id: "workflow-run-ops",
title: "Workflow Run Operations",
folder: "workflow-run-ops",
summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.",
content: workflowRunOpsSkill,
},
];
const skillEntries = definitions.map((definition) => ({
...definition,
catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`,
}));
const catalogSections = skillEntries.map((entry) => [
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath}\``,
`- **Use it for:** ${entry.summary}`,
].join("\n"));
export const skillCatalog = [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
"",
catalogSections.join("\n\n"),
].join("\n");
const normalizeIdentifier = (value: string) =>
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
const aliasMap = new Map<string, ResolvedSkill>();
const registerAlias = (alias: string, entry: ResolvedSkill) => {
const normalized = normalizeIdentifier(alias);
if (!normalized) return;
aliasMap.set(normalized, entry);
};
const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
const normalized = normalizeIdentifier(alias);
if (!normalized) return;
const variants = new Set<string>([normalized]);
if (/\.(ts|js)$/i.test(normalized)) {
variants.add(normalized.replace(/\.(ts|js)$/i, ""));
variants.add(
normalized.endsWith(".ts") ? normalized.replace(/\.ts$/i, ".js") : normalized.replace(/\.js$/i, ".ts"),
);
} else {
variants.add(`${normalized}.ts`);
variants.add(`${normalized}.js`);
}
for (const variant of variants) {
registerAlias(variant, entry);
}
};
for (const entry of skillEntries) {
const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js");
const resolvedEntry: ResolvedSkill = {
id: entry.id,
catalogPath: entry.catalogPath,
content: entry.content,
};
const baseAliases = [
entry.id,
entry.folder,
`${entry.folder}/skill`,
`${entry.folder}/skill.ts`,
`${entry.folder}/skill.js`,
`skills/${entry.folder}/skill.ts`,
`skills/${entry.folder}/skill.js`,
`${CATALOG_PREFIX}/${entry.folder}/skill.ts`,
`${CATALOG_PREFIX}/${entry.folder}/skill.js`,
absoluteTs,
absoluteJs,
];
for (const alias of baseAliases) {
registerAliasVariants(alias, resolvedEntry);
}
}
export const availableSkills = skillEntries.map((entry) => entry.id);
export function resolveSkill(identifier: string): ResolvedSkill | null {
const normalized = normalizeIdentifier(identifier);
if (!normalized) return null;
return aliasMap.get(normalized) ?? null;
}

View file

@ -0,0 +1,60 @@
export const skill = String.raw`
# MCP Integration Guidance
Load this skill whenever a user asks about external tools, MCP servers, or how to extend an agents capabilities.
## Key concepts
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.
- Agents reference MCP tools through the \`"tools"\` block by specifying \`type\`, \`name\`, \`description\`, \`mcpServerName\`, and a full \`inputSchema\`.
- Tool schemas can include optional property descriptions; only include \`"required"\` when parameters are mandatory.
## Operator actions
1. Use \`listMcpServers\` to enumerate configured servers.
2. Use \`listMcpTools\` for a server to understand the available operations and schemas.
3. Explain which MCP tools match the users needs before editing agent definitions.
4. When adding a tool to an agent, document what it does and ensure the schema mirrors the MCP definition.
## Example snippets to reference
- Firecrawl search (required param):
\`\`\`
"tools": {
"search": {
"type": "mcp",
"name": "firecrawl_search",
"description": "Search the web",
"mcpServerName": "firecrawl",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "number", "description": "Number of results"}
},
"required": ["query"]
}
}
}
\`\`\`
- ElevenLabs text-to-speech (no required array):
\`\`\`
"tools": {
"text_to_speech": {
"type": "mcp",
"name": "text_to_speech",
"description": "Generate audio from text",
"mcpServerName": "elevenLabs",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string"}
}
}
}
}
\`\`\`
## Safety reminders
- Only recommend MCP tools that are actually configured.
- Clarify any missing details (required parameters, server names) before modifying files.
`;
export default skill;

View file

@ -0,0 +1,168 @@
export const skill = String.raw`
# Agent and Workflow Authoring
Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace.
## Core Concepts
**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent.
- **All definitions live in \`agents/*.json\`** - there is no separate workflows folder
- Agents configure a model, instructions, and the tools they can use
- Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents**
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
## How multi-agent workflows work
1. **Create an orchestrator agent** that has other agents in its \`tools\`
2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\`
3. The orchestrator calls other agents as tools when needed
4. Data flows through tool call parameters and responses
## Agent format
\`\`\`json
{
"name": "agent_name",
"description": "Description of the agent",
"model": "gpt-5.1",
"instructions": "Instructions for the agent",
"tools": {
"descriptive_tool_key": {
"type": "mcp",
"name": "actual_mcp_tool_name",
"description": "What the tool does",
"mcpServerName": "server_name_from_config",
"inputSchema": {
"type": "object",
"properties": {
"param1": {"type": "string", "description": "What the parameter means"}
}
}
}
}
}
\`\`\`
## Tool types
### Builtin tools
\`\`\`json
"bash": {
"type": "builtin",
"name": "executeCommand"
}
\`\`\`
### MCP tools
\`\`\`json
"search": {
"type": "mcp",
"name": "firecrawl_search",
"description": "Search the web",
"mcpServerName": "firecrawl",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
\`\`\`
### Agent tools (for chaining agents)
\`\`\`json
"summariser": {
"type": "agent",
"name": "summariser_agent"
}
\`\`\`
- Use \`"type": "agent"\` to call other agents as tools
- The target agent will be invoked with the parameters you pass
- Results are returned as tool output
- This is how you build multi-agent workflows
## Complete Multi-Agent Workflow Example
**Podcast creation workflow** - This is all done through agents calling other agents:
**1. Task-specific agent** (does one thing):
\`\`\`json
{
"name": "summariser_agent",
"description": "Summarises an arxiv paper",
"model": "gpt-5.1",
"instructions": "Download and summarise an arxiv paper. Use curl to fetch the PDF. Output just the GIST in two lines. Don't ask for human input.",
"tools": {
"bash": {"type": "builtin", "name": "executeCommand"}
}
}
\`\`\`
**2. Agent that delegates to other agents**:
\`\`\`json
{
"name": "summarise-a-few",
"description": "Summarises multiple arxiv papers",
"model": "gpt-5.1",
"instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the tool. Don't ask for human input.",
"tools": {
"summariser": {
"type": "agent",
"name": "summariser_agent"
}
}
}
\`\`\`
**3. Orchestrator agent** (coordinates the whole workflow):
\`\`\`json
{
"name": "podcast_workflow",
"description": "Create a podcast from arXiv papers",
"model": "gpt-5.1",
"instructions": "1. Fetch arXiv papers about agents using bash\n2. Pick papers and summarise them using summarise_papers\n3. Create a podcast transcript\n4. Generate audio using text_to_speech\n\nExecute these steps in sequence.",
"tools": {
"bash": {"type": "builtin", "name": "executeCommand"},
"summarise_papers": {
"type": "agent",
"name": "summarise-a-few"
},
"text_to_speech": {
"type": "mcp",
"name": "text_to_speech",
"mcpServerName": "elevenLabs",
"description": "Generate audio",
"inputSchema": { "type": "object", "properties": {...}}
}
}
}
\`\`\`
**To run this workflow**: \`rowboatx --agent podcast_workflow\`
## Naming and organization rules
- **All agents live in \`agents/*.json\`** - no other location
- Agent filenames must match the \`"name"\` field exactly
- When referencing an agent as a tool, use its \`"name"\` value
- Always keep filenames and \`"name"\` fields perfectly aligned
- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users
## Best practices for multi-agent design
1. **Single responsibility**: Each agent should do one specific thing well
2. **Clear delegation**: Agent instructions should explicitly say when to call other agents
3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows
4. **Data passing**: Make it clear what data to extract and pass between agents
5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze")
6. **Orchestration**: Create a top-level agent that coordinates the workflow
## Capabilities checklist
1. Explore \`agents/\` directory to understand existing agents before editing
2. Update files carefully to maintain schema validity
3. When creating multi-agent workflows, create an orchestrator agent
4. Add other agents as tools with \`"type": "agent"\` for chaining
5. List and explore MCP servers/tools when users need new capabilities
6. Confirm work done and outline next steps once changes are complete
`;
export default skill;

View file

@ -0,0 +1,95 @@
export const skill = String.raw`
# Agent Run Operations
Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling.
## When to use
- User wants to run an agent (including multi-agent workflows)
- User wants to list or filter agent runs (all runs, by agent, time range, or paused for input)
- User wants to inspect cron jobs or change agent schedules
- User asks how to set up monitoring for waiting runs
## Running Agents
**To run any agent**:
\`\`\`bash
rowboatx --agent <agent-name>
\`\`\`
**With input**:
\`\`\`bash
rowboatx --agent <agent-name> --input "your input here"
\`\`\`
**Non-interactive** (for automation/cron):
\`\`\`bash
rowboatx --agent <agent-name> --input "input" --no-interactive
\`\`\`
**Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow.
## Run monitoring examples
Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed.
Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges.
Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}'
If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below.
1. **List all runs**
ls ~/.rowboat/runs
2. **Filter by agent**
grep -rl '"agent":"<agent-name>"' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r
Replace <agent-name> with the desired agent name.
3. **Filter by time window**
To the previous commands add the below through unix pipe
awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"'
Use the correct timestamps.
4. **Show runs waiting for human input**
awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}'
Prints the files whose last line equals 'pause-for-human-input'.
## Cron management examples
For scheduling agents to run automatically at specific times.
1. **View current cron schedule**
\`\`\`bash
crontab -l 2>/dev/null || echo 'No crontab entries configured.'
\`\`\`
2. **Schedule an agent to run periodically**
\`\`\`bash
(crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent <agent-name> --input "input" --no-interactive >> ~/.rowboat/logs/<agent-name>.log 2>&1') | crontab -
\`\`\`
Example (runs daily at 10 AM):
\`\`\`bash
(crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab -
\`\`\`
3. **Unschedule/remove an agent**
\`\`\`bash
crontab -l | grep -v '<agent-name>' | crontab -
\`\`\`
## Common cron schedule patterns
- \`0 10 * * *\` - Daily at 10 AM
- \`0 */6 * * *\` - Every 6 hours
- \`0 9 * * 1\` - Every Monday at 9 AM
- \`*/30 * * * *\` - Every 30 minutes
`;
export default skill;

View file

@ -0,0 +1,82 @@
import path from "path";
import fs from "fs";
import { McpServerConfig } from "../entities/mcp.js";
import { ModelConfig as ModelConfigT } from "../entities/models.js";
import { z } from "zod";
import { homedir } from "os";
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
const baseMcpConfig: z.infer<typeof McpServerConfig> = {
mcpServers: {
firecrawl: {
command: "npx",
args: ["-y", "supergateway", "--stdio", "npx -y firecrawl-mcp"],
env: {
FIRECRAWL_API_KEY: "fc-aaacee4bdd164100a4d83af85bef6fdc",
},
},
test: {
url: "http://localhost:3000",
headers: {
"Authorization": "Bearer test",
},
},
}
};
const baseModelConfig: z.infer<typeof ModelConfigT> = {
providers: {
openai: {
flavor: "openai",
},
},
defaults: {
provider: "openai",
model: "gpt-5.1",
}
};
function ensureMcpConfig() {
const configPath = path.join(WorkDir, "config", "mcp.json");
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify(baseMcpConfig, null, 2));
}
}
function ensureModelConfig() {
const configPath = path.join(WorkDir, "config", "models.json");
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify(baseModelConfig, null, 2));
}
}
function ensureDirs() {
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
ensure(WorkDir);
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "config"));
ensureMcpConfig();
ensureModelConfig();
}
ensureDirs();
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
const configPath = path.join(WorkDir, "config", "mcp.json");
if (!fs.existsSync(configPath)) return { mcpServers: {} };
const config = fs.readFileSync(configPath, "utf8");
return McpServerConfig.parse(JSON.parse(config));
}
function loadModelConfig(): z.infer<typeof ModelConfigT> {
const configPath = path.join(WorkDir, "config", "models.json");
if (!fs.existsSync(configPath)) return baseModelConfig;
const config = fs.readFileSync(configPath, "utf8");
return ModelConfigT.parse(JSON.parse(config));
}
const { mcpServers } = loadMcpServerConfig();
export const McpServers = mcpServers;
export const ModelConfig = loadModelConfig();

View file

@ -0,0 +1,101 @@
import path from "path";
import fs from "fs";
import { WorkDir } from "./config.js";
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
const DEFAULT_ALLOW_LIST = [
"cat",
"curl",
"date",
"echo",
"grep",
"jq",
"ls",
"pwd",
"yq",
"whoami"
]
let cachedAllowList: string[] | null = null;
let cachedMtimeMs: number | null = null;
function ensureSecurityConfig() {
if (!fs.existsSync(SECURITY_CONFIG_PATH)) {
fs.writeFileSync(
SECURITY_CONFIG_PATH,
JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n",
"utf8",
);
}
}
function normalizeList(commands: unknown[]): string[] {
const seen = new Set<string>();
for (const entry of commands) {
if (typeof entry !== "string") continue;
const normalized = entry.trim().toLowerCase();
if (!normalized) continue;
seen.add(normalized);
}
return Array.from(seen);
}
function parseSecurityPayload(payload: unknown): string[] {
if (Array.isArray(payload)) {
return normalizeList(payload);
}
if (payload && typeof payload === "object") {
const maybeObject = payload as Record<string, unknown>;
if (Array.isArray(maybeObject.allowedCommands)) {
return normalizeList(maybeObject.allowedCommands);
}
const dynamicList = Object.entries(maybeObject)
.filter(([, value]) => Boolean(value))
.map(([key]) => key);
return normalizeList(dynamicList);
}
return [];
}
function readAllowList(): string[] {
ensureSecurityConfig();
try {
const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8");
const parsed = JSON.parse(configContent);
return parseSecurityPayload(parsed);
} catch (error) {
console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`);
return DEFAULT_ALLOW_LIST;
}
}
export function getSecurityAllowList(): string[] {
ensureSecurityConfig();
try {
const stats = fs.statSync(SECURITY_CONFIG_PATH);
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedAllowList;
}
const allowList = readAllowList();
cachedAllowList = allowList;
cachedMtimeMs = stats.mtimeMs;
return allowList;
} catch {
cachedAllowList = null;
cachedMtimeMs = null;
return readAllowList();
}
}
export function resetSecurityAllowListCache() {
cachedAllowList = null;
cachedMtimeMs = null;
}

View file

@ -0,0 +1,35 @@
import { z } from "zod";
export const BaseTool = z.object({
name: z.string(),
});
export const BuiltinTool = BaseTool.extend({
type: z.literal("builtin"),
});
export const McpTool = BaseTool.extend({
type: z.literal("mcp"),
description: z.string(),
inputSchema: z.any(),
mcpServerName: z.string(),
});
export const AgentAsATool = BaseTool.extend({
type: z.literal("agent"),
});
export const ToolAttachment = z.discriminatedUnion("type", [
BuiltinTool,
McpTool,
AgentAsATool,
]);
export const Agent = z.object({
name: z.string(),
provider: z.string().optional(),
model: z.string().optional(),
description: z.string(),
instructions: z.string(),
tools: z.record(z.string(), ToolAttachment).optional(),
});

View file

@ -0,0 +1,56 @@
import { z } from "zod";
export const LlmStepStreamReasoningStartEvent = z.object({
type: z.literal("reasoning-start"),
});
export const LlmStepStreamReasoningDeltaEvent = z.object({
type: z.literal("reasoning-delta"),
delta: z.string(),
});
export const LlmStepStreamReasoningEndEvent = z.object({
type: z.literal("reasoning-end"),
});
export const LlmStepStreamTextStartEvent = z.object({
type: z.literal("text-start"),
});
export const LlmStepStreamTextDeltaEvent = z.object({
type: z.literal("text-delta"),
delta: z.string(),
});
export const LlmStepStreamTextEndEvent = z.object({
type: z.literal("text-end"),
});
export const LlmStepStreamToolCallEvent = z.object({
type: z.literal("tool-call"),
toolCallId: z.string(),
toolName: z.string(),
input: z.any(),
});
export const LlmStepStreamUsageEvent = z.object({
type: z.literal("usage"),
usage: z.object({
inputTokens: z.number().optional(),
outputTokens: z.number().optional(),
totalTokens: z.number().optional(),
reasoningTokens: z.number().optional(),
cachedInputTokens: z.number().optional(),
}),
});
export const LlmStepStreamEvent = z.union([
LlmStepStreamReasoningStartEvent,
LlmStepStreamReasoningDeltaEvent,
LlmStepStreamReasoningEndEvent,
LlmStepStreamTextStartEvent,
LlmStepStreamTextDeltaEvent,
LlmStepStreamTextEndEvent,
LlmStepStreamToolCallEvent,
LlmStepStreamUsageEvent,
]);

View file

@ -0,0 +1,16 @@
import z from "zod";
const StdioMcpServerConfig = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
const HttpMcpServerConfig = z.object({
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
});
export const McpServerConfig = z.object({
mcpServers: z.record(z.string(), z.union([StdioMcpServerConfig, HttpMcpServerConfig])),
});

View file

@ -0,0 +1,58 @@
import { z } from "zod";
export const TextPart = z.object({
type: z.literal("text"),
text: z.string(),
});
export const ReasoningPart = z.object({
type: z.literal("reasoning"),
text: z.string(),
});
export const ToolCallPart = z.object({
type: z.literal("tool-call"),
toolCallId: z.string(),
toolName: z.string(),
arguments: z.any(),
});
export const AssistantContentPart = z.union([
TextPart,
ReasoningPart,
ToolCallPart,
]);
export const UserMessage = z.object({
role: z.literal("user"),
content: z.string(),
});
export const AssistantMessage = z.object({
role: z.literal("assistant"),
content: z.union([
z.string(),
z.array(AssistantContentPart),
]),
});
export const SystemMessage = z.object({
role: z.literal("system"),
content: z.string(),
});
export const ToolMessage = z.object({
role: z.literal("tool"),
content: z.string(),
toolCallId: z.string(),
toolName: z.string(),
});
export const Message = z.discriminatedUnion("role", [
AssistantMessage,
SystemMessage,
ToolMessage,
UserMessage,
]);
export const MessageList = z.array(Message);

View file

@ -0,0 +1,15 @@
import z from "zod";
export const Provider = z.object({
flavor: z.enum(["openai", "anthropic", "google"]),
apiKey: z.string().optional(),
baseURL: z.string().optional(),
});
export const ModelConfig = z.object({
providers: z.record(z.string(), Provider),
defaults: z.object({
provider: z.string(),
model: z.string(),
}),
});

View file

@ -0,0 +1,85 @@
import { LlmStepStreamEvent } from "./llm-step-events.js";
import { Message, ToolCallPart } from "./message.js";
import { Agent } from "./agent.js";
import z from "zod";
const BaseRunEvent = z.object({
ts: z.iso.datetime().optional(),
subflow: z.array(z.string()),
});
export const StartEvent = BaseRunEvent.extend({
type: z.literal("start"),
runId: z.string(),
agentName: z.string(),
});
export const SpawnSubFlowEvent = BaseRunEvent.extend({
type: z.literal("spawn-subflow"),
agentName: z.string(),
toolCallId: z.string(),
});
export const LlmStreamEvent = BaseRunEvent.extend({
type: z.literal("llm-stream-event"),
event: LlmStepStreamEvent,
});
export const MessageEvent = BaseRunEvent.extend({
type: z.literal("message"),
message: Message,
});
export const ToolInvocationEvent = BaseRunEvent.extend({
type: z.literal("tool-invocation"),
toolName: z.string(),
input: z.string(),
});
export const ToolResultEvent = BaseRunEvent.extend({
type: z.literal("tool-result"),
toolName: z.string(),
result: z.any(),
});
export const AskHumanRequestEvent = BaseRunEvent.extend({
type: z.literal("ask-human-request"),
toolCallId: z.string(),
query: z.string(),
});
export const AskHumanResponseEvent = BaseRunEvent.extend({
type: z.literal("ask-human-response"),
toolCallId: z.string(),
response: z.string(),
});
export const ToolPermissionRequestEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-request"),
toolCall: ToolCallPart,
});
export const ToolPermissionResponseEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-response"),
toolCallId: z.string(),
response: z.enum(["approve", "deny"]),
});
export const RunErrorEvent = BaseRunEvent.extend({
type: z.literal("error"),
error: z.string(),
});
export const RunEvent = z.union([
StartEvent,
SpawnSubFlowEvent,
LlmStreamEvent,
MessageEvent,
ToolInvocationEvent,
ToolResultEvent,
AskHumanRequestEvent,
AskHumanResponseEvent,
ToolPermissionRequestEvent,
ToolPermissionResponseEvent,
RunErrorEvent,
]);

View file

@ -0,0 +1,653 @@
import { jsonSchema, ModelMessage } from "ai";
import fs from "fs";
import path from "path";
import { ModelConfig, WorkDir } from "../config/config.js";
import { Agent, ToolAttachment } from "../entities/agent.js";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js";
import { runIdGenerator } from "./run-id-gen.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
import { z } from "zod";
import { getProvider } from "./models.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { execTool } from "./exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
import { BuiltinTools } from "./builtin-tools.js";
import { CopilotAgent } from "../assistant/agent.js";
import { isBlocked } from "./command-executor.js";
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
switch (t.type) {
case "mcp":
return tool({
name: t.name,
description: t.description,
inputSchema: jsonSchema(t.inputSchema),
});
case "agent":
const agent = await loadAgent(t.name);
if (!agent) {
throw new Error(`Agent ${t.name} not found`);
}
return tool({
name: t.name,
description: agent.description,
inputSchema: z.object({
message: z.string().describe("The message to send to the workflow"),
}),
});
case "builtin":
if (t.name === "ask-human") {
return tool({
description: "Ask a human before proceeding",
inputSchema: z.object({
question: z.string().describe("The question to ask the human"),
}),
});
}
const match = BuiltinTools[t.name];
if (!match) {
throw new Error(`Unknown builtin tool: ${t.name}`);
}
return tool({
description: match.description,
inputSchema: match.inputSchema,
});
}
}
export class RunLogger {
private logFile: string;
private fileHandle: fs.WriteStream;
ensureRunsDir() {
const runsDir = path.join(WorkDir, "runs");
if (!fs.existsSync(runsDir)) {
fs.mkdirSync(runsDir, { recursive: true });
}
}
constructor(runId: string) {
this.ensureRunsDir();
this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
this.fileHandle = fs.createWriteStream(this.logFile, {
flags: "a",
encoding: "utf8",
});
}
log(event: z.infer<typeof RunEvent>) {
if (event.type !== "llm-stream-event") {
this.fileHandle.write(JSON.stringify(event) + "\n");
}
}
close() {
this.fileHandle.close();
}
}
export class StreamStepMessageBuilder {
private parts: z.infer<typeof AssistantContentPart>[] = [];
private textBuffer: string = "";
private reasoningBuffer: string = "";
flushBuffers() {
// skip reasoning
// if (this.reasoningBuffer) {
// this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
// this.reasoningBuffer = "";
// }
if (this.textBuffer) {
this.parts.push({ type: "text", text: this.textBuffer });
this.textBuffer = "";
}
}
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
switch (event.type) {
case "reasoning-start":
case "reasoning-end":
case "text-start":
case "text-end":
this.flushBuffers();
break;
case "reasoning-delta":
this.reasoningBuffer += event.delta;
break;
case "text-delta":
this.textBuffer += event.delta;
break;
case "tool-call":
this.parts.push({
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
arguments: event.input,
});
break;
}
}
get(): z.infer<typeof AssistantMessage> {
this.flushBuffers();
return {
role: "assistant",
content: this.parts,
};
}
}
function normaliseAskHumanToolCall(message: z.infer<typeof AssistantMessage>) {
if (typeof message.content === "string") {
return;
}
let askHumanToolCall: z.infer<typeof ToolCallPart> | null = null;
const newParts = [];
for (const part of message.content as z.infer<typeof AssistantContentPart>[]) {
if (part.type === "tool-call" && part.toolName === "ask-human") {
if (!askHumanToolCall) {
askHumanToolCall = part;
} else {
(askHumanToolCall as z.infer<typeof ToolCallPart>).arguments += "\n" + part.arguments;
}
break;
} else {
newParts.push(part);
}
}
if (askHumanToolCall) {
newParts.push(askHumanToolCall);
}
message.content = newParts;
}
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot") {
return CopilotAgent;
}
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
const agent = fs.readFileSync(agentPath, "utf8");
return Agent.parse(JSON.parse(agent));
}
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
switch (msg.role) {
case "assistant":
if (typeof msg.content === 'string') {
result.push({
role: "assistant",
content: msg.content,
});
} else {
result.push({
role: "assistant",
content: msg.content.map(part => {
switch (part.type) {
case 'text':
return part;
case 'reasoning':
return part;
case 'tool-call':
return {
type: 'tool-call',
toolCallId: part.toolCallId,
toolName: part.toolName,
input: part.arguments,
};
}
}),
});
}
break;
case "system":
result.push({
role: "system",
content: msg.content,
});
break;
case "user":
result.push({
role: "user",
content: msg.content,
});
break;
case "tool":
result.push({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: msg.toolCallId,
toolName: msg.toolName,
output: {
type: "text",
value: msg.content,
},
},
],
});
break;
}
}
return result;
}
async function buildTools(agent: z.infer<typeof Agent>): Promise<ToolSet> {
const tools: ToolSet = {};
for (const [name, tool] of Object.entries(agent.tools ?? {})) {
try {
tools[name] = await mapAgentTool(tool);
} catch (error) {
console.error(`Error mapping tool ${name}:`, error);
continue;
}
}
return tools;
}
export class AgentState {
logger: RunLogger | null = null;
runId: string | null = null;
agent: z.infer<typeof Agent> | null = null;
agentName: string;
messages: z.infer<typeof MessageList> = [];
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
subflowStates: Record<string, AgentState> = {};
toolCallIdMap: Record<string, z.infer<typeof ToolCallPart>> = {};
pendingToolCalls: Record<string, true> = {};
pendingToolPermissionRequests: Record<string, z.infer<typeof ToolPermissionRequestEvent>> = {};
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {};
constructor(agentName: string, runId?: string) {
this.agentName = agentName;
this.runId = runId || runIdGenerator.next();
this.logger = new RunLogger(this.runId);
if (!runId) {
this.logger.log({
type: "start",
runId: this.runId,
agentName: this.agentName,
subflow: [],
});
}
}
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
for (const perm of subflowState.getPendingPermissions()) {
response.push({
...perm,
subflow: [id, ...perm.subflow],
});
}
}
for (const perm of Object.values(this.pendingToolPermissionRequests)) {
response.push({
...perm,
subflow: [],
});
}
return response;
}
getPendingAskHumans(): z.infer<typeof AskHumanRequestEvent>[] {
const response: z.infer<typeof AskHumanRequestEvent>[] = [];
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
for (const ask of subflowState.getPendingAskHumans()) {
response.push({
...ask,
subflow: [id, ...ask.subflow],
});
}
}
for (const ask of Object.values(this.pendingAskHumanRequests)) {
response.push({
...ask,
subflow: [],
});
}
return response;
}
finalResponse(): string {
if (!this.lastAssistantMsg) {
return '';
}
if (typeof this.lastAssistantMsg.content === "string") {
return this.lastAssistantMsg.content;
}
return this.lastAssistantMsg.content.reduce((acc, part) => {
if (part.type === "text") {
return acc + part.text;
}
return acc;
}, "");
}
ingest(event: z.infer<typeof RunEvent>) {
if (event.subflow.length > 0) {
const { subflow, ...rest } = event;
this.subflowStates[subflow[0]].ingest({
...rest,
subflow: subflow.slice(1),
});
return;
}
switch (event.type) {
case "message":
this.messages.push(event.message);
if (event.message.content instanceof Array) {
for (const part of event.message.content) {
if (part.type === "tool-call") {
this.toolCallIdMap[part.toolCallId] = part;
this.pendingToolCalls[part.toolCallId] = true;
}
}
}
if (event.message.role === "tool") {
const message = event.message as z.infer<typeof ToolMessage>;
delete this.pendingToolCalls[message.toolCallId];
}
if (event.message.role === "assistant") {
this.lastAssistantMsg = event.message;
}
break;
case "spawn-subflow":
this.subflowStates[event.toolCallId] = new AgentState(event.agentName);
break;
case "tool-permission-request":
this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event;
break;
case "tool-permission-response":
switch (event.response) {
case "approve":
this.allowedToolCallIds[event.toolCallId] = true;
break;
case "deny":
this.deniedToolCallIds[event.toolCallId] = true;
break;
}
delete this.pendingToolPermissionRequests[event.toolCallId];
break;
case "ask-human-request":
this.pendingAskHumanRequests[event.toolCallId] = event;
break;
case "ask-human-response":
// console.error('im here', this.agentName, this.runId, event.subflow);
const ogEvent = this.pendingAskHumanRequests[event.toolCallId];
this.messages.push({
role: "tool",
content: JSON.stringify({
userResponse: event.response,
}),
toolCallId: ogEvent.toolCallId,
toolName: this.toolCallIdMap[ogEvent.toolCallId]!.toolName,
});
delete this.pendingAskHumanRequests[ogEvent.toolCallId];
break;
}
}
ingestAndLog(event: z.infer<typeof RunEvent>) {
this.ingest(event);
this.logger!.log(event);
}
*ingestAndLogAndYield(event: z.infer<typeof RunEvent>): Generator<z.infer<typeof RunEvent>, void, unknown> {
this.ingestAndLog(event);
yield event;
}
}
export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
// set up agent
const agent = await loadAgent(state.agentName);
// set up tools
const tools = await buildTools(agent);
// set up provider + model
const provider = getProvider(agent.provider);
const model = provider(agent.model || ModelConfig.defaults.model);
let loopCounter = 0;
while (true) {
// console.error(`loop counter: ${loopCounter++}`)
// if last response is from assistant and text, so exit
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage
&& lastMessage.role === "assistant"
&& (typeof lastMessage.content === "string"
|| !lastMessage.content.some(part => part.type === "tool-call")
)
) {
// console.error("Nothing to do, exiting (a.)")
return;
}
// execute any pending tool calls
for (const toolCallId of Object.keys(state.pendingToolCalls)) {
const toolCall = state.toolCallIdMap[toolCallId];
// if ask-human, skip
if (toolCall.toolName === "ask-human") {
continue;
}
// if tool has been denied, deny
if (state.deniedToolCallIds[toolCallId]) {
yield* state.ingestAndLogAndYield({
type: "message",
message: {
role: "tool",
content: "Unable to execute this tool: Permission was denied.",
toolCallId: toolCallId,
toolName: toolCall.toolName,
},
subflow: [],
});
continue;
}
// if permission is pending on this tool call, allow execution
if (state.pendingToolPermissionRequests[toolCallId]) {
continue;
}
// execute approved tool
yield* state.ingestAndLogAndYield({
type: "tool-invocation",
toolName: toolCall.toolName,
input: JSON.stringify(toolCall.arguments),
subflow: [],
});
let result: any = null;
if (agent.tools![toolCall.toolName].type === "agent") {
let subflowState = state.subflowStates[toolCallId];
for await (const event of streamAgent(subflowState)) {
yield* state.ingestAndLogAndYield({
...event,
subflow: [toolCallId, ...event.subflow],
});
}
if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) {
result = subflowState.finalResponse();
}
} else {
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments);
}
if (result) {
const resultMsg: z.infer<typeof ToolMessage> = {
role: "tool",
content: JSON.stringify(result),
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
};
yield* state.ingestAndLogAndYield({
type: "tool-result",
toolName: toolCall.toolName,
result: result,
subflow: [],
});
yield* state.ingestAndLogAndYield({
type: "message",
message: resultMsg,
subflow: [],
});
}
}
// if pending state, exit
if (state.getPendingAskHumans().length || state.getPendingPermissions().length) {
// console.error("pending asks or permissions, exiting (b.)")
return;
}
// if current message state isn't runnable, exit
if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") {
// console.error("current message state isn't runnable, exiting (c.)")
return;
}
// run one LLM turn.
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
for await (const event of streamLlm(
model,
state.messages,
agent.instructions,
tools,
)) {
messageBuilder.ingest(event);
yield* state.ingestAndLogAndYield({
type: "llm-stream-event",
event: event,
subflow: [],
});
}
// build and emit final message from agent response
const message = messageBuilder.get();
yield* state.ingestAndLogAndYield({
type: "message",
message,
subflow: [],
});
// if there were any ask-human calls, emit those events
if (message.content instanceof Array) {
for (const part of message.content) {
if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName];
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
yield* state.ingestAndLogAndYield({
type: "ask-human-request",
toolCallId: part.toolCallId,
query: part.arguments.question,
subflow: [],
});
}
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission
if (isBlocked(part.arguments.command)) {
yield *state.ingestAndLogAndYield({
type: "tool-permission-request",
toolCall: part,
subflow: [],
});
}
}
if (underlyingTool.type === "agent" && underlyingTool.name) {
yield* state.ingestAndLogAndYield({
type: "spawn-subflow",
agentName: underlyingTool.name,
toolCallId: part.toolCallId,
subflow: [],
});
yield* state.ingestAndLogAndYield({
type: "message",
message: {
role: "user",
content: part.arguments.message,
},
subflow: [part.toolCallId],
});
}
}
}
}
}
}
async function* streamLlm(
model: LanguageModel,
messages: z.infer<typeof MessageList>,
instructions: string,
tools: ToolSet,
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
const { fullStream } = streamText({
model,
messages: convertFromMessages(messages),
system: instructions,
tools,
stopWhen: stepCountIs(1),
});
for await (const event of fullStream) {
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
switch (event.type) {
case "reasoning-start":
yield {
type: "reasoning-start",
};
break;
case "reasoning-delta":
yield {
type: "reasoning-delta",
delta: event.text,
};
break;
case "reasoning-end":
yield {
type: "reasoning-end",
};
break;
case "text-start":
yield {
type: "text-start",
};
break;
case "text-delta":
yield {
type: "text-delta",
delta: event.text,
};
break;
case "tool-call":
yield {
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
};
break;
case "finish":
yield {
type: "usage",
usage: event.totalUsage,
};
break;
default:
// console.warn("Unknown event type", event);
continue;
}
}
}
export const MappedToolCall = z.object({
toolCall: ToolCallPart,
agentTool: ToolAttachment,
});

View file

@ -0,0 +1,457 @@
import { z, ZodType } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import { WorkDir as BASE_DIR } from "../config/config.js";
import { executeCommand } from "./command-executor.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),
inputSchema: z.custom<ZodType>(),
execute: z.function({
input: z.any(),
output: z.promise(z.any()),
}),
}));
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
inputSchema: z.object({
skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"),
}),
execute: async ({ skillName }: { skillName: string }) => {
const resolved = resolveSkill(skillName);
if (!resolved) {
return {
success: false,
message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`,
};
}
return {
success: true,
skillName: resolved.id,
path: resolved.catalogPath,
content: resolved.content,
};
},
},
exploreDirectory: {
description: 'Recursively explore directory structure to understand existing agents and file organization',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'),
maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'),
}),
execute: async ({ subdirectory, maxDepth = 3 }: { subdirectory?: string, maxDepth?: number }) => {
async function explore(dir: string, depth: number = 0): Promise<any> {
if (depth > maxDepth) return null;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const result: any = { files: [], directories: {} };
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
const ext = path.extname(entry.name);
const size = (await fs.stat(fullPath)).size;
result.files.push({
name: entry.name,
type: ext || 'no-extension',
size: size,
relativePath: path.relative(BASE_DIR, fullPath),
});
} else if (entry.isDirectory()) {
result.directories[entry.name] = await explore(fullPath, depth + 1);
}
}
return result;
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const structure = await explore(dirPath);
return {
success: true,
basePath: path.relative(BASE_DIR, dirPath) || '.',
structure,
};
},
},
readFile: {
description: 'Read and parse file contents. For JSON files, provides parsed structure.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const content = await fs.readFile(filePath, 'utf-8');
let parsed = null;
let fileType = path.extname(filename);
if (fileType === '.json') {
try {
parsed = JSON.parse(content);
} catch {
parsed = { error: 'Invalid JSON' };
}
}
return {
success: true,
filename,
fileType,
content,
parsed,
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
createFile: {
description: 'Create a new file with content. Automatically creates parent directories if needed.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'),
content: z.string().describe('The content to write to the file'),
description: z.string().optional().describe('Optional description of why this file is being created'),
}),
execute: async ({ filename, content, description }: { filename: string, content: string, description?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const dir = path.dirname(filePath);
// Ensure directory exists
await fs.mkdir(dir, { recursive: true });
// Write file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' created successfully`,
description: description || 'No description provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
updateFile: {
description: 'Update or overwrite the contents of an existing file',
inputSchema: z.object({
filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'),
content: z.string().describe('The new content to write to the file'),
reason: z.string().optional().describe('Optional reason for the update'),
}),
execute: async ({ filename, content, reason }: { filename: string, content: string, reason?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
// Check if file exists
await fs.access(filePath);
// Update file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' updated successfully`,
reason: reason || 'No reason provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
deleteFile: {
description: 'Delete a file from the .rowboat directory',
inputSchema: z.object({
filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
await fs.unlink(filePath);
return {
success: true,
message: `File '${filename}' deleted successfully`,
path: filePath,
};
} catch (error) {
return {
success: false,
message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listFiles: {
description: 'List all files and directories in the .rowboat directory or subdirectory',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'),
}),
execute: async ({ subdirectory }: { subdirectory?: string }) => {
try {
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => ({
name: entry.name,
type: path.extname(entry.name) || 'no-extension',
relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)),
}));
const directories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
return {
success: true,
path: dirPath,
relativePath: path.relative(BASE_DIR, dirPath) || '.',
files,
directories,
totalFiles: files.length,
totalDirectories: directories.length,
};
} catch (error) {
return {
success: false,
message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
analyzeAgent: {
description: 'Read and analyze an agent file to understand its structure, tools, and configuration',
inputSchema: z.object({
agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'),
}),
execute: async ({ agentName }: { agentName: string }) => {
try {
const filename = agentName.endsWith('.json') ? agentName : `${agentName}.json`;
const filePath = path.join(BASE_DIR, 'agents', filename);
const content = await fs.readFile(filePath, 'utf-8');
const agent = JSON.parse(content);
// Extract key information
const toolsList = agent.tools ? Object.keys(agent.tools) : [];
const agentTools = agent.tools ? Object.entries(agent.tools).map(([key, tool]: [string, any]) => ({
key,
type: tool.type,
name: tool.name || key,
})) : [];
const analysis = {
name: agent.name,
description: agent.description || 'No description',
model: agent.model || 'Not specified',
toolCount: toolsList.length,
tools: agentTools,
hasOtherAgents: agentTools.some((t: any) => t.type === 'agent'),
structure: agent,
};
return {
success: true,
filePath: path.relative(BASE_DIR, filePath),
analysis,
};
} catch (error) {
return {
success: false,
message: `Failed to analyze agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listMcpServers: {
description: 'List all available MCP servers from the configuration',
inputSchema: z.object({}),
execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
// Check if config exists
try {
await fs.access(configPath);
} catch {
return {
success: true,
servers: [],
count: 0,
message: 'No MCP servers configured yet',
};
}
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const servers = Object.keys(config.mcpServers || {}).map(name => {
const server = config.mcpServers[name];
return {
name,
type: 'command' in server ? 'stdio' : 'http',
command: server.command,
url: server.url,
};
});
return {
success: true,
servers,
count: servers.length,
message: `Found ${servers.length} MCP server(s)`,
};
} catch (error) {
return {
success: false,
servers: [],
count: 0,
message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listMcpTools: {
description: 'List all available tools from a specific MCP server',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server to query'),
}),
execute: async ({ serverName }: { serverName: string }) => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const mcpConfig = config.mcpServers[serverName];
if (!mcpConfig) {
return {
success: false,
message: `MCP server '${serverName}' not found in configuration`,
};
}
// Create transport based on config type
let transport;
if ('command' in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args || [],
env: mcpConfig.env || {},
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch {
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
// Create and connect client
const client = new Client({
name: 'rowboat-copilot',
version: '1.0.0',
});
await client.connect(transport);
// List available tools
const toolsList = await client.listTools();
// Close connection
client.close();
transport.close();
const tools = toolsList.tools.map((t: any) => ({
name: t.name,
description: t.description || 'No description',
inputSchema: t.inputSchema,
}));
return {
success: true,
serverName,
tools,
count: tools.length,
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
};
} catch (error) {
return {
success: false,
message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
executeCommand: {
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'),
}),
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
try {
const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR;
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
command,
workingDir,
};
} catch (error) {
return {
success: false,
message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`,
command,
};
}
},
},
};

View file

@ -0,0 +1,143 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../config/security.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, '');
}
function extractCommandNames(command: string): string[] {
const discovered = new Set<string>();
const segments = command.split(COMMAND_SPLIT_REGEX);
for (const segment of segments) {
const tokens = segment.trim().split(/\s+/).filter(Boolean);
if (!tokens.length) continue;
let index = 0;
while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index])) {
index++;
}
if (index >= tokens.length) continue;
const primary = sanitizeToken(tokens[index]).toLowerCase();
if (!primary) continue;
discovered.add(primary);
if (WRAPPER_COMMANDS.has(primary) && index + 1 < tokens.length) {
const wrapped = sanitizeToken(tokens[index + 1]).toLowerCase();
if (wrapped) {
discovered.add(wrapped);
}
}
}
return Array.from(discovered);
}
function findBlockedCommands(command: string): string[] {
const invoked = extractCommandNames(command);
if (!invoked.length) return [];
const allowList = getSecurityAllowList();
if (!allowList.length) return invoked;
const allowSet = new Set(allowList);
if (allowSet.has('*')) return [];
return invoked.filter((cmd) => !allowSet.has(cmd));
}
// export const BlockedResult = {
// stdout: '',
// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`,
// exitCode: 126,
// };
export function isBlocked(command: string): boolean {
const blocked = findBlockedCommands(command);
return blocked.length > 0;
}
export interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* Executes an arbitrary shell command
* @param command - The command to execute (e.g., "cat abc.txt | grep 'abc@gmail.com'")
* @param options - Optional execution options
* @returns Promise with stdout, stderr, and exit code
*/
export async function executeCommand(
command: string,
options?: {
cwd?: string;
timeout?: number; // timeout in milliseconds
maxBuffer?: number; // max buffer size in bytes
}
): Promise<CommandResult> {
try {
const { stdout, stderr } = await execPromise(command, {
cwd: options?.cwd,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: '/bin/sh', // use sh for cross-platform compatibility
});
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: 0,
};
} catch (error: any) {
// exec throws an error if the command fails or times out
return {
stdout: error.stdout?.trim() || '',
stderr: error.stderr?.trim() || error.message,
exitCode: error.code || 1,
};
}
}
/**
* Executes a command synchronously (blocking)
* Use with caution - prefer executeCommand for async execution
*/
export function executeCommandSync(
command: string,
options?: {
cwd?: string;
timeout?: number;
}
): CommandResult {
try {
const stdout = execSync(command, {
cwd: options?.cwd,
timeout: options?.timeout,
encoding: 'utf-8',
shell: '/bin/sh',
});
return {
stdout: stdout.trim(),
stderr: '',
exitCode: 0,
};
} catch (error: any) {
return {
stdout: error.stdout?.toString().trim() || '',
stderr: error.stderr?.toString().trim() || error.message,
exitCode: error.status || 1,
};
}
}

View file

@ -0,0 +1,65 @@
import { ToolAttachment } from "../entities/agent.js";
import { z } from "zod";
import { McpServers } from "../config/config.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { BuiltinTools } from "./builtin-tools.js";
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: any): Promise<any> {
// load mcp configuration from the tool
const mcpConfig = McpServers[agentTool.mcpServerName];
if (!mcpConfig) {
throw new Error(`MCP server ${agentTool.mcpServerName} not found`);
}
// create transport
let transport: Transport;
if ("command" in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args,
env: mcpConfig.env,
});
} else {
// first try streamable http transport
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch (error) {
// if that fails, try sse transport
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
if (!transport) {
throw new Error(`No transport found for ${agentTool.mcpServerName}`);
}
// create client
const client = new Client({
name: 'rowboatx',
version: '1.0.0',
});
await client.connect(transport);
// call tool
const result = await client.callTool({ name: agentTool.name, arguments: input });
client.close();
transport.close();
return result;
}
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: any): Promise<any> {
switch (agentTool.type) {
case "mcp":
return execMcpTool(agentTool, input);
case "builtin":
const builtinTool = BuiltinTools[agentTool.name];
if (!builtinTool || !builtinTool.execute) {
throw new Error(`Unsupported builtin tool: ${agentTool.name}`);
}
return builtinTool.execute(input);
}
}

View file

@ -0,0 +1,31 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
export async function getMcpClient(serverUrl: string, serverName: string): Promise<Client> {
let client: Client | undefined = undefined;
const baseUrl = new URL(serverUrl);
// Try to connect using Streamable HTTP transport
try {
client = new Client({
name: 'streamable-http-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`);
return client;
} catch (error) {
// If that fails with a 4xx error, try the older SSE transport
console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`);
client = new Client({
name: 'sse-client',
version: '1.0.0'
});
const sseTransport = new SSEClientTransport(baseUrl);
await client.connect(sseTransport);
console.log(`[MCP] Connected using SSE transport to ${serverName}`);
return client;
}
}

View file

@ -0,0 +1,40 @@
import { createOpenAI, OpenAIProvider } from "@ai-sdk/openai";
import { createGoogleGenerativeAI, GoogleGenerativeAIProvider } from "@ai-sdk/google";
import { AnthropicProvider, createAnthropic } from "@ai-sdk/anthropic";
import { ModelConfig } from "../config/config.js";
const providerMap: Record<string, OpenAIProvider | GoogleGenerativeAIProvider | AnthropicProvider> = {};
export function getProvider(name: string = "") {
if (!name) {
name = ModelConfig.defaults.provider;
}
if (providerMap[name]) {
return providerMap[name];
}
const providerConfig = ModelConfig.providers[name];
if (!providerConfig) {
throw new Error(`Provider ${name} not found`);
}
switch (providerConfig.flavor) {
case "openai":
providerMap[name] = createOpenAI({
apiKey: providerConfig.apiKey,
baseURL: providerConfig.baseURL,
});
break;
case "anthropic":
providerMap[name] = createAnthropic({
apiKey: providerConfig.apiKey,
baseURL: providerConfig.baseURL,
});
break;
case "google":
providerMap[name] = createGoogleGenerativeAI({
apiKey: providerConfig.apiKey,
baseURL: providerConfig.baseURL,
});
break;
}
return providerMap[name];
}

View file

@ -0,0 +1,7 @@
import { customAlphabet } from 'nanoid';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz-';
const nanoid = customAlphabet(alphabet, 7);
export async function randomId(): Promise<string> {
return nanoid();
}

View file

@ -0,0 +1,32 @@
class RunIdGenerator {
private lastMs = 0;
private seq = 0;
private readonly pid: string;
private readonly hostTag: string;
constructor(hostTag: string = "") {
this.pid = String(process.pid).padStart(7, "0");
this.hostTag = hostTag ? `-${hostTag}` : "";
}
/**
* Returns an ISO8601-based, lexicographically sortable id string.
* Example: 2025-11-11T04-36-29Z-0001234-h1-000
*/
next(): string {
const now = Date.now();
const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp
this.seq = ms === this.lastMs ? this.seq + 1 : 0;
this.lastMs = ms;
// Build ISO string (UTC) and remove milliseconds for cleaner filenames
const iso = new Date(ms).toISOString() // e.g. 2025-11-11T04:36:29.123Z
.replace(/\.\d{3}Z$/, "Z") // drop .123 part
.replace(/:/g, "-"); // safe for files: 2025-11-11T04-36-29Z
const seqStr = String(this.seq).padStart(3, "0");
return `${iso}-${this.pid}${this.hostTag}-${seqStr}`;
}
}
export const runIdGenerator = new RunIdGenerator();

View file

@ -0,0 +1,13 @@
import { MessageList } from "../entities/message.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { z } from "zod";
import { ToolAttachment } from "../entities/agent.js";
export type StepInputT = z.infer<typeof MessageList>;
export type StepOutputT = AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown>;
export interface Step {
execute(input: StepInputT): StepOutputT;
tools(): Record<string, z.infer<typeof ToolAttachment>>;
}

View file

@ -0,0 +1,297 @@
import { z } from "zod";
import { RunEvent } from "../entities/run-events.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
export interface StreamRendererOptions {
showHeaders?: boolean;
dimReasoning?: boolean;
jsonIndent?: number;
truncateJsonAt?: number;
}
export class StreamRenderer {
private options: Required<StreamRendererOptions>;
private reasoningActive = false;
private textActive = false;
private firstText = true;
constructor(options?: StreamRendererOptions) {
this.options = {
showHeaders: true,
dimReasoning: true,
jsonIndent: 2,
truncateJsonAt: 500,
...options,
};
}
render(event: z.infer<typeof RunEvent>) {
switch (event.type) {
case "start": {
this.onStart(event.agentName, event.runId);
break;
}
case "llm-stream-event": {
this.renderLlmEvent(event.event);
break;
}
case "message": {
// this.onStepMessage(event.stepId, event.message);
break;
}
case "tool-invocation": {
this.onStepToolInvocation(event.toolName, event.input);
break;
}
case "tool-result": {
this.onStepToolResult(event.toolName, event.result);
break;
}
case "error": {
this.onError(event.error);
break;
}
}
}
private renderLlmEvent(event: z.infer<typeof LlmStepStreamEvent>) {
switch (event.type) {
case "reasoning-start":
this.onReasoningStart();
break;
case "reasoning-delta":
this.onReasoningDelta(event.delta);
break;
case "reasoning-end":
this.onReasoningEnd();
break;
case "text-start":
this.onTextStart();
break;
case "text-delta":
this.onTextDelta(event.delta);
break;
case "text-end":
this.onTextEnd();
break;
case "tool-call":
this.onToolCall(event.toolCallId, event.toolName, event.input);
break;
case "usage":
this.onUsage(event.usage);
break;
}
}
private onStart(agentName: string, runId: string) {
this.write("\n");
this.write(this.bold(`▶ Agent ${agentName} (run ${runId})`));
this.write("\n");
this.write(this.dim(`╰─────────────────────────────────────────────────\n`));
}
private onEnd() {
this.write("\n");
this.write(this.dim("─".repeat(50)));
this.write("\n");
this.write(this.green(this.bold("✓ Complete")));
this.write("\n\n");
}
private onError(error: string) {
this.write("\n");
this.write(this.red(this.bold("✖ Error")));
this.write("\n");
this.write(this.red(this.indent(error)));
this.write("\n\n");
}
private onStepStart() {
this.write("\n");
this.write(this.dim("│ "));
this.write(this.dim("Step in progress..."));
this.write("\n");
}
private onStepEnd() {
// More subtle step end - just add a little spacing
this.write(this.dim("\n"));
}
private onStepMessage(stepIndex: number, message: any) {
const role = message?.role ?? "message";
const content = message?.content;
this.write(this.bold(`${role}: `));
if (typeof content === "string") {
this.write(content + "\n");
} else {
const pretty = this.truncate(JSON.stringify(message, null, this.options.jsonIndent));
this.write(this.dim("\n" + this.indent(pretty) + "\n"));
}
}
private onStepToolInvocation(toolName: string, input: string) {
this.write("\n");
this.write(this.cyan("┌─ ") + this.bold(this.cyan(`🔧 ${toolName}`)));
this.write("\n");
if (input && input.length) {
this.write(this.dim("│ ") + this.dim(this.indent(this.truncate(input)).replace(/\n/g, "\n│ ")));
this.write("\n");
}
}
private onStepToolResult(toolName: string, result: unknown) {
const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent));
this.write(this.dim("│\n"));
this.write(this.green("└─ ") + this.dim(this.green(`Result`)));
this.write("\n");
this.write(this.dim(" " + this.indent(res).replace(/\n/g, "\n ")));
this.write("\n");
}
private onReasoningStart() {
if (this.reasoningActive) return;
this.reasoningActive = true;
if (this.options.showHeaders) {
this.write("\n");
this.write(this.dim("│ "));
this.write(this.dim(this.italic("thinking... ")));
}
}
private onReasoningDelta(delta: string) {
if (!this.reasoningActive) this.onReasoningStart();
this.write(this.options.dimReasoning ? this.dim(delta) : delta);
}
private onReasoningEnd() {
if (!this.reasoningActive) return;
this.reasoningActive = false;
this.write("\n");
}
private onTextStart() {
if (this.textActive) return;
this.textActive = true;
if (this.options.showHeaders && this.firstText) {
this.write("\n");
this.write(this.bold("╭─ ") + this.bold("Response"));
this.write("\n");
this.write(this.dim("│\n"));
this.firstText = false;
} else if (this.options.showHeaders) {
this.write("\n");
this.write(this.dim("│ "));
}
}
private onTextDelta(delta: string) {
// Add subtle left margin to assistant text for better readability
const formattedDelta = this.neutral(delta);
if (delta.includes("\n")) {
this.write(formattedDelta.replace(/\n/g, "\n "));
} else {
this.write(formattedDelta);
}
}
private onTextEnd() {
if (!this.textActive) return;
this.textActive = false;
this.write("\n");
}
private onToolCall(toolCallId: string, toolName: string, input: unknown) {
const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent));
this.write("\n");
this.write(this.magenta("┌─ ") + this.bold(this.magenta(`${toolName}`)));
this.write(this.dim(` (${toolCallId.slice(0, 8)}...)`));
this.write("\n");
this.write(this.dim("│ ") + this.dim(this.indent(inputStr).replace(/\n/g, "\n│ ")));
this.write("\n");
this.write(this.dim("└─────────────\n"));
}
private onPauseForHumanInput(toolCallId: string, question: string) {
this.write(this.cyan(`\n→ Pause for human input (${toolCallId})`));
this.write("\n");
this.write(this.bold("Question: ") + question);
this.write("\n");
}
private onUsage(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cachedInputTokens?: number;
}) {
const parts: string[] = [];
if (usage.inputTokens !== undefined) parts.push(`${this.dim("in:")} ${usage.inputTokens}`);
if (usage.outputTokens !== undefined) parts.push(`${this.dim("out:")} ${usage.outputTokens}`);
if (usage.reasoningTokens !== undefined) parts.push(`${this.dim("reasoning:")} ${usage.reasoningTokens}`);
if (usage.cachedInputTokens !== undefined) parts.push(`${this.dim("cached:")} ${usage.cachedInputTokens}`);
if (usage.totalTokens !== undefined) parts.push(`${this.dim("total:")} ${this.bold(usage.totalTokens.toString())}`);
const line = parts.join(this.dim(" | "));
this.write("\n");
this.write(this.dim("╭─ Usage\n"));
this.write(this.dim("│ ") + line);
this.write("\n");
this.write(this.dim("╰─────────────\n"));
}
// Formatting helpers
private write(text: string) {
process.stdout.write(text);
}
private indent(text: string): string {
return text
.split("\n")
.map((line) => (line.length ? ` ${line}` : line))
.join("\n");
}
private truncate(text: string): string {
if (text.length <= this.options.truncateJsonAt) return text;
return text.slice(0, this.options.truncateJsonAt) + "…";
}
private bold(text: string): string {
return "\x1b[1m" + text + "\x1b[0m";
}
private dim(text: string): string {
return "\x1b[2m" + text + "\x1b[0m";
}
private italic(text: string): string {
return "\x1b[3m" + text + "\x1b[0m";
}
private cyan(text: string): string {
return "\x1b[36m" + text + "\x1b[0m";
}
private green(text: string): string {
return "\x1b[32m" + text + "\x1b[0m";
}
private red(text: string): string {
return "\x1b[31m" + text + "\x1b[0m";
}
private magenta(text: string): string {
return "\x1b[35m" + text + "\x1b[0m";
}
private yellow(text: string): string {
return "\x1b[33m" + text + "\x1b[0m";
}
private neutral(text: string): string {
return "\x1b[38;5;250m" + text + "\x1b[0m";
}
}

15
apps/cli/todo.md Normal file
View file

@ -0,0 +1,15 @@
runtime
---
o stream out responses
o terminal logging
o file logging
- accept initial user input from CLI
- mcp tool calls (http + stdio)
- human input support
- bash tool support
- cli wrapper (node commander)
rowboat agent
---
- create agent

20
apps/cli/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"module": "nodenext",
"target": "esnext",
"lib": ["esnext"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View file

@ -157,6 +157,7 @@ export async function createComposioTriggerDeployment(request: {
export async function listComposioTriggerDeployments(request: { export async function listComposioTriggerDeployments(request: {
projectId: string, projectId: string,
cursor?: string, cursor?: string,
limit?: number,
}) { }) {
const user = await authCheck(); const user = await authCheck();
@ -166,6 +167,7 @@ export async function listComposioTriggerDeployments(request: {
userId: user.id, userId: user.id,
projectId: request.projectId, projectId: request.projectId,
cursor: request.cursor, cursor: request.cursor,
limit: request.limit,
}); });
} }
@ -191,4 +193,4 @@ export async function fetchComposioTriggerDeployment(request: { deploymentId: st
userId: user.id, userId: user.id,
deploymentId: request.deploymentId, deploymentId: request.deploymentId,
}); });
} }

View file

@ -3,6 +3,7 @@ import {
CopilotAPIRequest, CopilotAPIRequest,
CopilotChatContext, CopilotMessage, CopilotChatContext, CopilotMessage,
DataSourceSchemaForCopilot, DataSourceSchemaForCopilot,
TriggerSchemaForCopilot,
} from "../../src/entities/models/copilot"; } from "../../src/entities/models/copilot";
import { import {
Workflow} from "../lib/types/workflow_types"; Workflow} from "../lib/types/workflow_types";
@ -26,7 +27,8 @@ export async function getCopilotResponseStream(
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
current_workflow_config: z.infer<typeof Workflow>, current_workflow_config: z.infer<typeof Workflow>,
context: z.infer<typeof CopilotChatContext> | null, context: z.infer<typeof CopilotChatContext> | null,
dataSources?: z.infer<typeof DataSourceSchemaForCopilot>[] dataSources?: z.infer<typeof DataSourceSchemaForCopilot>[],
triggers?: z.infer<typeof TriggerSchemaForCopilot>[]
): Promise<{ ): Promise<{
streamId: string; streamId: string;
} | { billingError: string }> { } | { billingError: string }> {
@ -42,6 +44,7 @@ export async function getCopilotResponseStream(
workflow: current_workflow_config, workflow: current_workflow_config,
context, context,
dataSources, dataSources,
triggers,
} }
}); });
return { return {

View file

@ -6,6 +6,7 @@ import { IListRecurringJobRulesController } from "@/src/interface-adapters/contr
import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller"; import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller"; import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller"; import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { IUpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller";
import { authCheck } from "./auth.actions"; import { authCheck } from "./auth.actions";
import { z } from "zod"; import { z } from "zod";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
@ -15,6 +16,7 @@ const listRecurringJobRulesController = container.resolve<IListRecurringJobRules
const fetchRecurringJobRuleController = container.resolve<IFetchRecurringJobRuleController>('fetchRecurringJobRuleController'); const fetchRecurringJobRuleController = container.resolve<IFetchRecurringJobRuleController>('fetchRecurringJobRuleController');
const toggleRecurringJobRuleController = container.resolve<IToggleRecurringJobRuleController>('toggleRecurringJobRuleController'); const toggleRecurringJobRuleController = container.resolve<IToggleRecurringJobRuleController>('toggleRecurringJobRuleController');
const deleteRecurringJobRuleController = container.resolve<IDeleteRecurringJobRuleController>('deleteRecurringJobRuleController'); const deleteRecurringJobRuleController = container.resolve<IDeleteRecurringJobRuleController>('deleteRecurringJobRuleController');
const updateRecurringJobRuleController = container.resolve<IUpdateRecurringJobRuleController>('updateRecurringJobRuleController');
export async function createRecurringJobRule(request: { export async function createRecurringJobRule(request: {
projectId: string, projectId: string,
@ -89,3 +91,23 @@ export async function deleteRecurringJobRule(request: {
ruleId: request.ruleId, ruleId: request.ruleId,
}); });
} }
export async function updateRecurringJobRule(request: {
projectId: string,
ruleId: string,
input: {
messages: z.infer<typeof Message>[],
},
cron: string,
}) {
const user = await authCheck();
return await updateRecurringJobRuleController.execute({
caller: 'user',
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
input: request.input,
cron: request.cron,
});
}

View file

@ -5,6 +5,7 @@ import { ICreateScheduledJobRuleController } from "@/src/interface-adapters/cont
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller"; import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller"; import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller"; import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { IUpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller";
import { authCheck } from "./auth.actions"; import { authCheck } from "./auth.actions";
import { z } from "zod"; import { z } from "zod";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
@ -13,6 +14,7 @@ const createScheduledJobRuleController = container.resolve<ICreateScheduledJobRu
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController'); const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const fetchScheduledJobRuleController = container.resolve<IFetchScheduledJobRuleController>('fetchScheduledJobRuleController'); const fetchScheduledJobRuleController = container.resolve<IFetchScheduledJobRuleController>('fetchScheduledJobRuleController');
const deleteScheduledJobRuleController = container.resolve<IDeleteScheduledJobRuleController>('deleteScheduledJobRuleController'); const deleteScheduledJobRuleController = container.resolve<IDeleteScheduledJobRuleController>('deleteScheduledJobRuleController');
const updateScheduledJobRuleController = container.resolve<IUpdateScheduledJobRuleController>('updateScheduledJobRuleController');
export async function createScheduledJobRule(request: { export async function createScheduledJobRule(request: {
projectId: string, projectId: string,
@ -72,4 +74,24 @@ export async function deleteScheduledJobRule(request: {
projectId: request.projectId, projectId: request.projectId,
ruleId: request.ruleId, ruleId: request.ruleId,
}); });
} }
export async function updateScheduledJobRule(request: {
projectId: string,
ruleId: string,
input: {
messages: z.infer<typeof Message>[],
},
scheduledTime: string,
}) {
const user = await authCheck();
return await updateScheduledJobRuleController.execute({
caller: 'user',
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
input: request.input,
scheduledTime: request.scheduledTime,
});
}

View file

@ -1,4 +1,5 @@
import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types"; import { WorkflowTool, WorkflowAgent, WorkflowPrompt, WorkflowPipeline } from "./types/workflow_types";
import { Message } from "./types/types";
import { z } from "zod"; import { z } from "zod";
const ZFallbackSchema = z.object({}).passthrough(); const ZFallbackSchema = z.object({}).passthrough();
@ -62,6 +63,40 @@ export function validateConfigChanges(configType: string, configChanges: Record<
testObject = {}; testObject = {};
break; break;
} }
case 'one_time_trigger': {
testObject = {
scheduledTime: new Date(0).toISOString(),
input: {
messages: [],
},
};
schema = z.object({
scheduledTime: z.string().min(1),
input: z.object({
messages: z.array(Message),
}),
}).passthrough();
break;
}
case 'recurring_trigger': {
testObject = {
cron: '* * * * *',
input: {
messages: [],
},
};
schema = z.object({
cron: z.string().min(1),
input: z.object({
messages: z.array(Message),
}),
}).passthrough();
break;
}
case 'external_trigger': {
// External triggers have flexible schemas per provider; do not strip any config.
return { changes: configChanges };
}
default: default:
return { error: `Unknown config type: ${configType}` }; return { error: `Unknown config type: ${configType}` };
} }

View file

@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner, Tooltip } from "@heroui/react"; import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger, Spinner, Tooltip } from "@heroui/react";
import { useRef, useState, createContext, useContext, useCallback, forwardRef, useImperativeHandle, useEffect, Ref } from "react"; import { useRef, useState, createContext, useContext, useCallback, forwardRef, useImperativeHandle, useEffect, Ref } from "react";
import { CopilotChatContext } from "../../../../src/entities/models/copilot"; import { CopilotChatContext, TriggerSchemaForCopilot } from "../../../../src/entities/models/copilot";
import { CopilotMessage } from "../../../../src/entities/models/copilot"; import { CopilotMessage } from "../../../../src/entities/models/copilot";
import { Workflow } from "@/app/lib/types/workflow_types"; import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source"; import { DataSource } from "@/src/entities/models/data-source";
@ -36,6 +36,8 @@ interface AppProps {
onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void; onMessagesChange?: (messages: z.infer<typeof CopilotMessage>[]) => void;
isInitialState?: boolean; isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[]; dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
onTriggersUpdated?: () => Promise<void> | void;
} }
const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }, AppProps>(function App({ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message: string) => void }, AppProps>(function App({
@ -47,6 +49,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onMessagesChange, onMessagesChange,
isInitialState = false, isInitialState = false,
dataSources, dataSources,
triggers,
onTriggersUpdated,
}, ref) { }, ref) {
@ -85,7 +89,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
projectId, projectId,
workflow: workflowRef.current, workflow: workflowRef.current,
context: effectiveContext, context: effectiveContext,
dataSources: dataSources dataSources: dataSources,
triggers: triggers
}); });
// Store latest start/cancel functions in refs // Store latest start/cancel functions in refs
@ -255,6 +260,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
</div> </div>
)} )}
<Messages <Messages
projectId={projectId}
messages={messages} messages={messages}
streamingResponse={streamingResponse} streamingResponse={streamingResponse}
loadingResponse={loadingResponse} loadingResponse={loadingResponse}
@ -263,6 +269,8 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
onStatusBarChange={handleStatusBarChange} onStatusBarChange={handleStatusBarChange}
toolCalling={toolCalling} toolCalling={toolCalling}
toolQuery={toolQuery} toolQuery={toolQuery}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
/> />
</div> </div>
<div className="shrink-0 px-0 pb-10"> <div className="shrink-0 px-0 pb-10">
@ -318,8 +326,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch: (action: WorkflowDispatch) => void; dispatch: (action: WorkflowDispatch) => void;
isInitialState?: boolean; isInitialState?: boolean;
dataSources?: z.infer<typeof DataSource>[]; dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
activePanel?: 'playground' | 'copilot'; activePanel?: 'playground' | 'copilot';
onTogglePanel?: () => void; onTogglePanel?: () => void;
onTriggersUpdated?: () => Promise<void> | void;
}>(({ }>(({
projectId, projectId,
workflow, workflow,
@ -327,8 +337,10 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
dispatch, dispatch,
isInitialState = false, isInitialState = false,
dataSources, dataSources,
triggers,
activePanel, activePanel,
onTogglePanel, onTogglePanel,
onTriggersUpdated,
}, ref) => { }, ref) => {
console.log('🎪 Copilot wrapper component mounted:', { console.log('🎪 Copilot wrapper component mounted:', {
projectId, projectId,
@ -414,6 +426,8 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
onMessagesChange={setMessages} onMessagesChange={setMessages}
isInitialState={isInitialState} isInitialState={isInitialState}
dataSources={dataSources} dataSources={dataSources}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
/> />
</div> </div>
</Panel> </Panel>

View file

@ -0,0 +1,237 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/react';
import { z } from 'zod';
import { ZToolkit } from '@/src/application/lib/composio/types';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { Project } from '@/src/entities/models/project';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from '../../workflow/components/ComposioTriggerTypesPanel';
import { TriggerConfigForm } from '../../workflow/components/TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { fetchProject } from '@/app/actions/project.actions';
import { createComposioTriggerDeployment } from '@/app/actions/composio.actions';
import { Button, Spinner } from '@heroui/react';
interface TriggerSetupModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
initialToolkitSlug?: string | null;
initialTriggerTypeSlug?: string | null;
initialTriggerConfig?: Record<string, unknown> | null;
onCreated?: () => void;
}
type Toolkit = z.infer<typeof ZToolkit>;
type TriggerType = z.infer<typeof ComposioTriggerType>;
type ProjectConfig = z.infer<typeof Project>;
export function TriggerSetupModal({
isOpen,
onClose,
projectId,
initialToolkitSlug = null,
initialTriggerTypeSlug = null,
initialTriggerConfig = null,
onCreated,
}: TriggerSetupModalProps) {
const [selectedToolkit, setSelectedToolkit] = useState<Toolkit | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<TriggerType | null>(null);
const [projectConfig, setProjectConfig] = useState<ProjectConfig | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingTriggerTypeSlug, setPendingTriggerTypeSlug] = useState<string | null>(null);
const [initialConfig, setInitialConfig] = useState<Record<string, unknown> | undefined>();
const loadProjectConfig = useCallback(async () => {
try {
const config = await fetchProject(projectId);
setProjectConfig(config);
} catch (err) {
console.error('Failed to fetch project configuration', err);
}
}, [projectId]);
const resetState = useCallback(() => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setError(null);
setPendingTriggerTypeSlug(initialTriggerTypeSlug);
setInitialConfig(initialTriggerConfig ?? undefined);
}, [initialTriggerConfig, initialTriggerTypeSlug]);
useEffect(() => {
if (!isOpen) {
return;
}
resetState();
void loadProjectConfig();
}, [isOpen, loadProjectConfig, resetState]);
const requiresAuth = useMemo(() => {
if (!selectedToolkit) return false;
return !selectedToolkit.no_auth;
}, [selectedToolkit]);
const hasActiveConnection = useMemo(() => {
if (!selectedToolkit) return false;
const status = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status;
return status === 'ACTIVE';
}, [projectConfig, selectedToolkit]);
const handleSelectToolkit = useCallback((toolkit: Toolkit) => {
setSelectedToolkit(toolkit);
setSelectedTriggerType(null);
setError(null);
if (!initialToolkitSlug || toolkit.slug === initialToolkitSlug) {
setPendingTriggerTypeSlug(initialTriggerTypeSlug);
} else {
setPendingTriggerTypeSlug(null);
}
}, [initialToolkitSlug, initialTriggerTypeSlug]);
const handleSelectTriggerType = useCallback((triggerType: TriggerType) => {
setSelectedTriggerType(triggerType);
setError(null);
setPendingTriggerTypeSlug(null);
if (requiresAuth && !hasActiveConnection) {
setShowAuthModal(true);
}
}, [requiresAuth, hasActiveConnection]);
const handleAuthComplete = useCallback(async () => {
await loadProjectConfig();
setShowAuthModal(false);
}, [loadProjectConfig]);
const handleSubmit = useCallback(async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) {
return;
}
try {
setIsSubmitting(true);
setError(null);
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
setShowAuthModal(true);
throw new Error('Connect this toolkit before creating a trigger.');
}
await createComposioTriggerDeployment({
projectId,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
onCreated?.();
onClose();
} catch (err: any) {
console.error('Failed to create trigger', err);
setError(err?.message || 'Failed to create trigger. Please try again.');
} finally {
setIsSubmitting(false);
}
}, [onClose, onCreated, projectConfig, projectId, selectedToolkit, selectedTriggerType]);
const handleClose = useCallback(() => {
if (isSubmitting) {
return;
}
onClose();
}, [isSubmitting, onClose]);
return (
<>
<Modal
isOpen={isOpen}
onClose={handleClose}
size="5xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[90vh]'
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Set up External Trigger</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Follow the guided flow to authenticate and configure the trigger.
</p>
</ModalHeader>
<ModalBody className="pb-6">
{!selectedToolkit && (
<SelectComposioToolkit
key={isOpen ? 'toolkit-selector' : 'toolkit-selector-hidden'}
projectId={projectId}
tools={[]}
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={initialToolkitSlug}
filterByTriggers={true}
/>
)}
{selectedToolkit && !selectedTriggerType && (
<ComposioTriggerTypesPanel
key={selectedToolkit.slug}
toolkit={selectedToolkit}
onBack={() => setSelectedToolkit(null)}
onSelectTriggerType={handleSelectTriggerType}
initialTriggerTypeSlug={pendingTriggerTypeSlug}
/>
)}
{selectedToolkit && selectedTriggerType && (!requiresAuth || hasActiveConnection) && (
<div className="space-y-4">
<div>
<Button variant="light" size="sm" onPress={() => setSelectedTriggerType(null)}>
Back
</Button>
</div>
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={() => setSelectedTriggerType(null)}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
initialConfig={initialConfig}
/>
</div>
)}
{selectedToolkit && selectedTriggerType && requiresAuth && !hasActiveConnection && !showAuthModal && (
<div className="py-12 text-center space-y-4">
<Spinner className="mx-auto" />
<p className="text-sm text-gray-600 dark:text-gray-300">
Waiting for authentication to complete...
</p>
</div>
)}
{error && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-3 text-sm text-red-600 dark:text-red-300">
{error}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
projectId={projectId}
toolkitSlug={selectedToolkit.slug}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -29,6 +29,7 @@ export function Action({
onApplied, onApplied,
externallyApplied = false, externallyApplied = false,
defaultExpanded = false, defaultExpanded = false,
onRequestTriggerSetup,
}: { }: {
msgIndex: number; msgIndex: number;
actionIndex: number; actionIndex: number;
@ -39,10 +40,12 @@ export function Action({
onApplied?: () => void; onApplied?: () => void;
externallyApplied?: boolean; externallyApplied?: boolean;
defaultExpanded?: boolean; defaultExpanded?: boolean;
onRequestTriggerSetup?: (params: { action: z.infer<typeof CopilotAssistantMessageActionPart>['content']; msgIndex: number; actionIndex: number }) => void;
}) { }) {
const { showPreview } = usePreviewModal(); const { showPreview } = usePreviewModal();
const [expanded, setExpanded] = useState(defaultExpanded); const [expanded, setExpanded] = useState(defaultExpanded);
const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({}); const [appliedChanges, setAppliedChanges] = useState<Record<string, boolean>>({});
const isExternalTriggerCreate = action.config_type === 'external_trigger' && action.action === 'create_new';
if (!action || typeof action !== 'object') { if (!action || typeof action !== 'object') {
console.warn('Invalid action object:', action); console.warn('Invalid action object:', action);
@ -108,6 +111,10 @@ export function Action({
// Handle applying all changes - delegate to parent // Handle applying all changes - delegate to parent
const handleApplyAll = () => { const handleApplyAll = () => {
if (isExternalTriggerCreate) {
onRequestTriggerSetup?.({ action, msgIndex, actionIndex });
return;
}
// Mark all fields as applied locally for UI state // Mark all fields as applied locally for UI state
const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => { const appliedKeys = Object.keys(action.config_changes).reduce((acc, key) => {
acc[getAppliedChangeKey(msgIndex, actionIndex, key)] = true; acc[getAppliedChangeKey(msgIndex, actionIndex, key)] = true;
@ -211,7 +218,7 @@ export function Action({
{action.config_type === 'tool' && toolkitLogo ? ( {action.config_type === 'tool' && toolkitLogo ? (
<PictureImg src={toolkitLogo} alt={"Toolkit logo"} className="h-5 w-5 object-contain" /> <PictureImg src={toolkitLogo} alt={"Toolkit logo"} className="h-5 w-5 object-contain" />
) : ( ) : (
action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : '💬' action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'prompt' ? '💬' : action.config_type === 'one_time_trigger' ? '⏰' : action.config_type === 'recurring_trigger' ? '🔄' : action.config_type === 'external_trigger' ? '🔗' : '💬'
)} )}
</span> </span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1"> <span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
@ -230,9 +237,9 @@ export function Action({
onClick={() => handleApplyAll()} onClick={() => handleApplyAll()}
> >
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} /> <CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
<span>{allApplied ? 'Applied' : 'Apply'}</span> <span>{allApplied ? 'Applied' : isExternalTriggerCreate ? 'Open setup' : 'Apply'}</span>
</button> </button>
{action.action !== 'delete' && <button {action.action !== 'delete' && !isExternalTriggerCreate && <button
className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors" className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors"
onClick={handleViewDiff} onClick={handleViewDiff}
> >
@ -379,7 +386,7 @@ export function StreamingAction({
}: { }: {
action: { action: {
action?: 'create_new' | 'edit' | 'delete'; action?: 'create_new' | 'edit' | 'delete';
config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent'; config_type?: 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger';
name?: string; name?: string;
}; };
loading: boolean; loading: boolean;
@ -418,7 +425,7 @@ export function StreamingAction({
'bg-gray-200 text-gray-600': !action.action, 'bg-gray-200 text-gray-600': !action.action,
} }
)}> )}>
{action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : '💬'} {action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : action.config_type === 'pipeline' ? '⚙️' : action.config_type === 'start_agent' ? '🏁' : action.config_type === 'one_time_trigger' ? '⏰' : action.config_type === 'recurring_trigger' ? '🔄' : action.config_type === 'external_trigger' ? '🔗' : '💬'}
</span> </span>
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1"> <span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
{action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name} {action.action === 'create_new' ? 'Add' : action.action === 'edit' ? 'Edit' : 'Delete'} {action.config_type}: {action.name}
@ -444,4 +451,4 @@ export function StreamingAction({
</div> </div>
</div> </div>
); );
} }

View file

@ -5,12 +5,16 @@ import { z } from "zod";
import { Workflow} from "@/app/lib/types/workflow_types"; import { Workflow} from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content"; import MarkdownContent from "@/app/lib/components/markdown-content";
import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react"; import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react";
import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/src/entities/models/copilot"; import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Action, StreamingAction } from './actions'; import { Action, StreamingAction } from './actions';
import { TriggerSetupModal } from './TriggerSetupModal';
import { useCopilotTriggerActions } from './use-trigger-actions';
import { useParsedBlocks } from "../use-parsed-blocks"; import { useParsedBlocks } from "../use-parsed-blocks";
import { validateConfigChanges } from "@/app/lib/client_utils"; import { validateConfigChanges } from "@/app/lib/client_utils";
import { PreviewModalProvider } from '../../workflow/preview-modal'; import { PreviewModalProvider } from '../../workflow/preview-modal';
type CopilotTriggerType = z.infer<typeof TriggerSchemaForCopilot>;
const CopilotResponsePart = z.union([ const CopilotResponsePart = z.union([
z.object({ z.object({
type: z.literal('text'), type: z.literal('text'),
@ -71,7 +75,7 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
type: 'action', type: 'action',
action: { action: {
action: metadata.action as 'create_new' | 'edit' | 'delete', action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent', config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger',
name: metadata.name, name: metadata.name,
change_description: jsonData.change_description || '', change_description: jsonData.change_description || '',
config_changes: {}, config_changes: {},
@ -80,15 +84,27 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
}; };
} }
const actionPayload = {
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
};
if (actionPayload.config_type === 'external_trigger' && actionPayload.action === 'edit') {
return {
type: 'action',
action: {
...actionPayload,
error: "Editing external triggers isn't supported. Delete the trigger and create a new one with the updated settings—I can take care of that for you if you'd like."
}
};
}
return { return {
type: 'action', type: 'action',
action: { action: actionPayload
action: metadata.action as 'create_new' | 'edit' | 'delete',
config_type: metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent',
name: metadata.name,
change_description: jsonData.change_description || '',
config_changes: result.changes
}
}; };
} }
} catch (e) { } catch (e) {
@ -100,7 +116,7 @@ function enrich(response: string): z.infer<typeof CopilotResponsePart> {
type: 'streaming_action', type: 'streaming_action',
action: { action: {
action: (metadata.action as 'create_new' | 'edit' | 'delete') || undefined, action: (metadata.action as 'create_new' | 'edit' | 'delete') || undefined,
config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent') || undefined, config_type: (metadata.config_type as 'tool' | 'agent' | 'prompt' | 'pipeline' | 'start_agent' | 'one_time_trigger' | 'recurring_trigger' | 'external_trigger') || undefined,
name: metadata.name name: metadata.name
} }
}; };
@ -171,18 +187,23 @@ function AssistantMessage({
dispatch, dispatch,
messageIndex, messageIndex,
loading, loading,
onStatusBarChange onStatusBarChange,
projectId,
triggers,
onTriggersUpdated,
}: { }: {
content: z.infer<typeof CopilotAssistantMessage>['content'], content: z.infer<typeof CopilotAssistantMessage>['content'],
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
dispatch: (action: any) => void, dispatch: (action: any) => void,
messageIndex: number, messageIndex: number,
loading: boolean, loading: boolean,
onStatusBarChange?: (status: any) => void onStatusBarChange?: (status: any) => void;
projectId: string;
triggers?: CopilotTriggerType[];
onTriggersUpdated?: () => Promise<void> | void;
}) { }) {
const blocks = useParsedBlocks(content); const blocks = useParsedBlocks(content);
const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set()); const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set());
// Remove autoApplyEnabled and useEffect for auto-apply
// parse actions from parts // parse actions from parts
const parsed = useMemo(() => { const parsed = useMemo(() => {
@ -200,6 +221,46 @@ function AssistantMessage({
return result; return result;
}, [blocks]); }, [blocks]);
const hasUpcomingReplacement = useCallback((candidate: z.infer<typeof CopilotAssistantMessageActionPart>['content'], currentIndex: number = -1) => {
return parsed.some((part, idx) =>
idx > currentIndex &&
part.type === 'action' &&
part.action.config_type === candidate.config_type &&
part.action.name === candidate.name &&
part.action.action === 'create_new'
);
}, [parsed]);
const {
triggerSetupModal,
requestTriggerSetup,
closeTriggerSetup,
handleTriggerCreatedViaModal,
handleTriggerAction,
} = useCopilotTriggerActions({
projectId,
triggers,
onTriggersUpdated,
hasUpcomingReplacement,
});
const handleTriggerSetupCreated = useCallback(async () => {
if (!triggerSetupModal) {
return;
}
const index = triggerSetupModal.actionIndex;
setAppliedActions(prev => {
const next = new Set(prev);
next.add(index);
return next;
});
await handleTriggerCreatedViaModal();
}, [handleTriggerCreatedViaModal, triggerSetupModal]);
const handleTriggerSetupClosed = useCallback(() => {
closeTriggerSetup();
}, [closeTriggerSetup]);
// Count action cards for tracking // Count action cards for tracking
const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action'); const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
const totalActions = parsed.filter(part => part.type === 'action').length; const totalActions = parsed.filter(part => part.type === 'action').length;
@ -208,14 +269,12 @@ function AssistantMessage({
const allApplied = pendingCount === 0 && totalActions > 0; const allApplied = pendingCount === 0 && totalActions > 0;
// Memoized applyAction for useCallback dependencies // Memoized applyAction for useCallback dependencies
const applyAction = useCallback((action: any, actionIndex: number) => { const applyAction = useCallback((action: any): boolean => {
// Only apply, do not update appliedActions here
if (action.action === 'create_new') { if (action.action === 'create_new') {
switch (action.config_type) { switch (action.config_type) {
case 'agent': { case 'agent': {
// Prevent duplicate agent names
if (workflow.agents.some((agent: any) => agent.name === action.name)) { if (workflow.agents.some((agent: any) => agent.name === action.name)) {
return; return false;
} }
dispatch({ dispatch({
type: 'add_agent', type: 'add_agent',
@ -225,12 +284,11 @@ function AssistantMessage({
}, },
fromCopilot: true fromCopilot: true
}); });
break; return true;
} }
case 'tool': { case 'tool': {
// Prevent duplicate tool names
if (workflow.tools.some((tool: any) => tool.name === action.name)) { if (workflow.tools.some((tool: any) => tool.name === action.name)) {
return; return false;
} }
dispatch({ dispatch({
type: 'add_tool', type: 'add_tool',
@ -240,7 +298,7 @@ function AssistantMessage({
}, },
fromCopilot: true fromCopilot: true
}); });
break; return true;
} }
case 'prompt': case 'prompt':
dispatch({ dispatch({
@ -251,7 +309,7 @@ function AssistantMessage({
}, },
fromCopilot: true fromCopilot: true
}); });
break; return true;
case 'pipeline': case 'pipeline':
dispatch({ dispatch({
type: 'add_pipeline', type: 'add_pipeline',
@ -261,7 +319,7 @@ function AssistantMessage({
}, },
fromCopilot: true fromCopilot: true
}); });
break; return true;
} }
} else if (action.action === 'edit') { } else if (action.action === 'edit') {
switch (action.config_type) { switch (action.config_type) {
@ -271,34 +329,34 @@ function AssistantMessage({
name: action.name, name: action.name,
agent: action.config_changes agent: action.config_changes
}); });
break; return true;
case 'tool': case 'tool':
dispatch({ dispatch({
type: 'update_tool_no_select', type: 'update_tool_no_select',
name: action.name, name: action.name,
tool: action.config_changes tool: action.config_changes
}); });
break; return true;
case 'prompt': case 'prompt':
dispatch({ dispatch({
type: 'update_prompt', type: 'update_prompt',
name: action.name, name: action.name,
prompt: action.config_changes prompt: action.config_changes
}); });
break; return true;
case 'pipeline': case 'pipeline':
dispatch({ dispatch({
type: 'update_pipeline', type: 'update_pipeline',
name: action.name, name: action.name,
pipeline: action.config_changes pipeline: action.config_changes
}); });
break; return true;
case 'start_agent': case 'start_agent':
dispatch({ dispatch({
type: 'set_main_agent', type: 'set_main_agent',
name: action.name, name: action.name,
}) });
break; return true;
} }
} else if (action.action === 'delete') { } else if (action.action === 'delete') {
switch (action.config_type) { switch (action.config_type) {
@ -307,72 +365,93 @@ function AssistantMessage({
type: 'delete_agent', type: 'delete_agent',
name: action.name name: action.name
}); });
break; return true;
case 'tool': case 'tool':
dispatch({ dispatch({
type: 'delete_tool', type: 'delete_tool',
name: action.name name: action.name
}); });
break; return true;
case 'prompt': case 'prompt':
dispatch({ dispatch({
type: 'delete_prompt', type: 'delete_prompt',
name: action.name name: action.name
}); });
break; return true;
case 'pipeline': case 'pipeline':
dispatch({ dispatch({
type: 'delete_pipeline', type: 'delete_pipeline',
name: action.name name: action.name
}); });
break; return true;
} }
} }
console.warn('Unhandled action from Copilot applyAction', action);
return false;
}, [dispatch, workflow.agents, workflow.tools]); }, [dispatch, workflow.agents, workflow.tools]);
// Memoized handleApplyAll for useEffect dependencies // Memoized handleApplyAll for useEffect dependencies
const handleApplyAll = useCallback(() => { const handleApplyAll = useCallback(async () => {
// Find all unapplied action indices const unapplied = parsed.reduce<Array<{ action: z.infer<typeof CopilotAssistantMessageActionPart>['content']; actionIndex: number }>>((acc, part, idx) => {
const unapplied = parsed if (part.type === 'action' && !appliedActions.has(idx)) {
.map((part, idx) => ({ part, actionIndex: idx })) acc.push({ action: part.action, actionIndex: idx });
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex)) }
.map(({ part, actionIndex }) => ({ return acc;
action: part.type === 'action' ? part.action : null, }, []);
actionIndex
}))
.filter(({ action }) => action !== null);
// Synchronously apply all unapplied actions const newlyApplied: number[] = [];
unapplied.forEach(({ action, actionIndex }) => {
applyAction(action, actionIndex);
});
// After all are applied, update the state in one go for (const { action, actionIndex } of unapplied) {
setAppliedActions(prev => { try {
const next = new Set(prev); const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
unapplied.forEach(({ actionIndex }) => next.add(actionIndex)); const success = isTrigger
return next; ? await handleTriggerAction(action, { actionIndex, messageIndex })
}); : applyAction(action);
}, [parsed, appliedActions, setAppliedActions, applyAction]);
if (success) {
newlyApplied.push(actionIndex);
}
} catch (error) {
console.error('Failed to apply Copilot action', action, error);
}
}
if (newlyApplied.length > 0) {
setAppliedActions(prev => {
const next = new Set(prev);
newlyApplied.forEach(index => next.add(index));
return next;
});
}
}, [parsed, appliedActions, applyAction, handleTriggerAction, messageIndex]);
// Manual single apply (from card) // Manual single apply (from card)
const handleSingleApply = (action: any, actionIndex: number) => { const handleSingleApply = useCallback(async (action: z.infer<typeof CopilotAssistantMessageActionPart>['content'], actionIndex: number) => {
if (!appliedActions.has(actionIndex)) { if (appliedActions.has(actionIndex)) {
applyAction(action, actionIndex); return;
setAppliedActions(prev => new Set([...prev, actionIndex]));
} }
};
try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger
? await handleTriggerAction(action, { actionIndex, messageIndex })
: applyAction(action);
if (success) {
setAppliedActions(prev => new Set([...prev, actionIndex]));
}
} catch (error) {
console.error('Failed to apply Copilot action', action, error);
}
}, [appliedActions, applyAction, handleTriggerAction, messageIndex]);
useEffect(() => { useEffect(() => {
if (loading) { if (loading) {
// setAutoApplyEnabled(false); // Removed
setAppliedActions(new Set()); setAppliedActions(new Set());
// setPanelOpen(false); // Removed
} }
}, [loading]); }, [loading]);
// Removed useEffect for auto-apply
// Find streaming/ongoing card and extract name // Find streaming/ongoing card and extract name
const streamingPart = parsed.find(part => part.type === 'streaming_action'); const streamingPart = parsed.find(part => part.type === 'streaming_action');
let streamingLine = ''; let streamingLine = '';
@ -389,8 +468,8 @@ function AssistantMessage({
const createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length; const createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length;
const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length; const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length;
const parts = []; const parts = [];
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`); if (createCount > 0) parts.push(`${createCount} item${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`); if (editCount > 0) parts.push(`${editCount} item${editCount > 1 ? 's' : ''} updated`);
completedSummary = parts.join(', '); completedSummary = parts.join(', ');
} }
@ -412,9 +491,6 @@ function AssistantMessage({
} }
// At the end of the render, call onStatusBarChange with the current status bar props // At the end of the render, call onStatusBarChange with the current status bar props
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
// Only call onStatusBarChange if the serializable status actually changes // Only call onStatusBarChange if the serializable status actually changes
const lastStatusRef = useRef<any>(null); const lastStatusRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
@ -442,6 +518,7 @@ function AssistantMessage({
// Render all cards inline, not in a panel // Render all cards inline, not in a panel
return ( return (
<>
<div className="w-full"> <div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200"> <div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -461,9 +538,12 @@ function AssistantMessage({
workflow={workflow} workflow={workflow}
dispatch={dispatch} dispatch={dispatch}
stale={false} stale={false}
onApplied={() => handleSingleApply(part.action, idx)} onApplied={() => { void handleSingleApply(part.action, idx); }}
externallyApplied={appliedActions.has(idx)} externallyApplied={appliedActions.has(idx)}
defaultExpanded={true} defaultExpanded={true}
onRequestTriggerSetup={({ action, actionIndex }) =>
requestTriggerSetup({ action, actionIndex, messageIndex })
}
/> />
); );
} }
@ -482,6 +562,16 @@ function AssistantMessage({
</div> </div>
</div> </div>
</div> </div>
<TriggerSetupModal
isOpen={Boolean(triggerSetupModal)}
onClose={handleTriggerSetupClosed}
projectId={projectId}
initialToolkitSlug={triggerSetupModal?.initialToolkitSlug ?? null}
initialTriggerTypeSlug={triggerSetupModal?.initialTriggerTypeSlug ?? null}
initialTriggerConfig={triggerSetupModal?.initialConfig}
onCreated={handleTriggerSetupCreated}
/>
</>
); );
} }
@ -506,6 +596,7 @@ function AssistantMessageLoading({ currentStatus }: { currentStatus: 'thinking'
} }
export function Messages({ export function Messages({
projectId,
messages, messages,
streamingResponse, streamingResponse,
loadingResponse, loadingResponse,
@ -513,8 +604,11 @@ export function Messages({
dispatch, dispatch,
onStatusBarChange, onStatusBarChange,
toolCalling, toolCalling,
toolQuery toolQuery,
triggers,
onTriggersUpdated,
}: { }: {
projectId: string;
messages: z.infer<typeof CopilotMessage>[]; messages: z.infer<typeof CopilotMessage>[];
streamingResponse: string; streamingResponse: string;
loadingResponse: boolean; loadingResponse: boolean;
@ -523,6 +617,8 @@ export function Messages({
onStatusBarChange?: (status: any) => void; onStatusBarChange?: (status: any) => void;
toolCalling?: boolean; toolCalling?: boolean;
toolQuery?: string | null; toolQuery?: string | null;
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
onTriggersUpdated?: () => Promise<void> | void;
}) { }) {
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [displayMessages, setDisplayMessages] = useState(messages); const [displayMessages, setDisplayMessages] = useState(messages);
@ -551,9 +647,6 @@ export function Messages({
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [messages, loadingResponse]); }, [messages, loadingResponse]);
// Track the latest status bar info
const latestStatusBar = useRef<any>(null);
const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => { const renderMessage = (message: z.infer<typeof CopilotMessage>, messageIndex: number) => {
if (message.role === 'assistant') { if (message.role === 'assistant') {
return ( return (
@ -564,10 +657,12 @@ export function Messages({
dispatch={dispatch} dispatch={dispatch}
messageIndex={messageIndex} messageIndex={messageIndex}
loading={loadingResponse} loading={loadingResponse}
projectId={projectId}
triggers={triggers}
onTriggersUpdated={onTriggersUpdated}
onStatusBarChange={status => { onStatusBarChange={status => {
// Only update for the last assistant message // Only update for the last assistant message
if (messageIndex === displayMessages.length - 1) { if (messageIndex === displayMessages.length - 1) {
latestStatusBar.current = status;
onStatusBarChange?.(status); onStatusBarChange?.(status);
} }
}} }}
@ -603,4 +698,4 @@ export function Messages({
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
); );
} }

View file

@ -0,0 +1,465 @@
'use client';
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { CopilotAssistantMessageActionPart, TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Message } from "@/app/lib/types/types";
type ScheduledJobActionsModule = typeof import('@/app/actions/scheduled-job-rules.actions');
type RecurringJobActionsModule = typeof import('@/app/actions/recurring-job-rules.actions');
type ComposioActionsModule = typeof import('@/app/actions/composio.actions');
type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
type CopilotAction = z.infer<typeof CopilotAssistantMessageActionPart>['content'];
export interface TriggerSetupModalState {
action: CopilotAction;
actionIndex: number;
messageIndex: number;
initialToolkitSlug: string | null;
initialTriggerTypeSlug: string | null;
initialConfig?: Record<string, unknown>;
}
interface UseCopilotTriggerActionsParams {
projectId: string;
triggers?: CopilotTrigger[];
onTriggersUpdated?: () => Promise<void> | void;
hasUpcomingReplacement: (action: CopilotAction, currentIndex?: number) => boolean;
}
interface UseCopilotTriggerActionsResult {
triggerSetupModal: TriggerSetupModalState | null;
requestTriggerSetup: (params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => void;
closeTriggerSetup: () => void;
handleTriggerCreatedViaModal: () => Promise<void>;
handleTriggerAction: (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => Promise<boolean>;
}
let scheduledJobActionsPromise: Promise<ScheduledJobActionsModule> | null = null;
let recurringJobActionsPromise: Promise<RecurringJobActionsModule> | null = null;
let composioActionsPromise: Promise<ComposioActionsModule> | null = null;
function loadScheduledJobActions(): Promise<ScheduledJobActionsModule> {
if (!scheduledJobActionsPromise) {
scheduledJobActionsPromise = import('@/app/actions/scheduled-job-rules.actions');
}
return scheduledJobActionsPromise;
}
function loadRecurringJobActions(): Promise<RecurringJobActionsModule> {
if (!recurringJobActionsPromise) {
recurringJobActionsPromise = import('@/app/actions/recurring-job-rules.actions');
}
return recurringJobActionsPromise;
}
function loadComposioActions(): Promise<ComposioActionsModule> {
if (!composioActionsPromise) {
composioActionsPromise = import('@/app/actions/composio.actions');
}
return composioActionsPromise;
}
const hasOwn = (obj: Record<string, unknown> | undefined, key: string) =>
!!obj && Object.prototype.hasOwnProperty.call(obj, key);
const buildTriggerKey = (configType: string, name: string) => `${configType}:${name}`;
const toStringOrNull = (value: unknown): string | null => {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
return null;
};
const extractSlug = (primary: unknown, secondary: unknown, tertiary: unknown): string | null => {
return (
toStringOrNull(primary) ??
toStringOrNull(secondary) ??
(typeof tertiary === 'object' && tertiary !== null ? toStringOrNull((tertiary as { slug?: unknown }).slug) : toStringOrNull(tertiary))
);
};
const TriggerInputSchema = z.object({
messages: z.array(Message),
});
type TriggerInput = z.infer<typeof TriggerInputSchema>;
const coerceTriggerInput = (value: unknown, fallback?: TriggerInput | null): TriggerInput | null => {
if (value) {
const parsed = TriggerInputSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
}
return fallback ?? null;
};
const extractTriggerSetupState = (
params: { action: CopilotAction; actionIndex: number; messageIndex: number }
): TriggerSetupModalState => {
const { action, actionIndex, messageIndex } = params;
const changes = (action?.config_changes ?? {}) as Record<string, unknown>;
const initialToolkitSlug = extractSlug(changes.toolkitSlug, changes.toolkit_slug, changes.toolkit);
const initialTriggerTypeSlug = extractSlug(changes.triggerTypeSlug, changes.trigger_type_slug, changes.triggerType);
const triggerConfigCandidate = (changes.triggerConfig ?? changes.trigger_config ?? changes.config) as unknown;
const initialConfig = typeof triggerConfigCandidate === 'object' && triggerConfigCandidate !== null
? (triggerConfigCandidate as Record<string, unknown>)
: undefined;
return {
action,
actionIndex,
messageIndex,
initialToolkitSlug,
initialTriggerTypeSlug,
initialConfig,
};
};
export function useCopilotTriggerActions({
projectId,
triggers,
onTriggersUpdated,
hasUpcomingReplacement,
}: UseCopilotTriggerActionsParams): UseCopilotTriggerActionsResult {
const [triggerSetupModal, setTriggerSetupModal] = useState<TriggerSetupModalState | null>(null);
const triggersRef = useRef<CopilotTrigger[]>(triggers ?? []);
const pendingTriggerEditsRef = useRef<Map<string, CopilotTrigger>>(new Map());
useEffect(() => {
triggersRef.current = triggers ?? [];
pendingTriggerEditsRef.current.clear();
}, [triggers]);
const refreshTriggers = useCallback(async () => {
if (!onTriggersUpdated) {
return;
}
await onTriggersUpdated();
}, [onTriggersUpdated]);
const requestTriggerSetup = useCallback((params: { action: CopilotAction; actionIndex: number; messageIndex: number }) => {
setTriggerSetupModal(prev => {
if (prev && prev.actionIndex === params.actionIndex && prev.messageIndex === params.messageIndex) {
return prev;
}
return extractTriggerSetupState(params);
});
}, []);
const closeTriggerSetup = useCallback(() => {
setTriggerSetupModal(null);
}, []);
const handleTriggerCreatedViaModal = useCallback(async () => {
await refreshTriggers();
closeTriggerSetup();
}, [refreshTriggers, closeTriggerSetup]);
const handleOneTimeTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
const triggerList = triggersRef.current;
const key = buildTriggerKey(action.config_type, action.name);
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
let mutated = false;
const actionIndex = context?.actionIndex;
if (action.action === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
const { createScheduledJobRule, updateScheduledJobRule } = await loadScheduledJobActions();
if (pending && pending.type === 'one_time') {
const scheduledTime = (actionChanges.scheduledTime as string) ?? pending.nextRunAt;
const input = coerceTriggerInput(actionChanges.input, pending.input);
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger update via replacement', action);
return false;
}
await updateScheduledJobRule({
projectId,
ruleId: pending.id,
scheduledTime,
input,
});
pendingTriggerEditsRef.current.delete(key);
mutated = true;
} else {
const scheduledTime = actionChanges.scheduledTime as string | undefined;
const input = coerceTriggerInput(actionChanges.input);
if (!scheduledTime || !input) {
console.error('Missing scheduledTime or input for one-time trigger creation', action);
return false;
}
await createScheduledJobRule({
projectId,
scheduledTime,
input,
});
mutated = true;
}
return mutated;
}
const target = triggerList.find(
(trigger): trigger is Extract<CopilotTrigger, { type: 'one_time' }> =>
trigger.type === 'one_time' && trigger.name === action.name
);
if (!target) {
console.warn('Unable to resolve one-time trigger for action', action.name);
return false;
}
const {
fetchScheduledJobRule,
deleteScheduledJobRule,
updateScheduledJobRule,
} = await loadScheduledJobActions();
if (action.action === 'delete') {
if (hasUpcomingReplacement(action, actionIndex)) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteScheduledJobRule({ projectId, ruleId: target.id });
mutated = true;
return mutated;
}
if (action.action === 'edit') {
const existing = await fetchScheduledJobRule({ ruleId: target.id });
if (!existing) {
console.error('Failed to load existing one-time trigger for edit', action.name);
return false;
}
const scheduledTime = (actionChanges.scheduledTime as string) ?? existing.nextRunAt;
const input = coerceTriggerInput(actionChanges.input, existing.input);
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger edit', action);
return false;
}
await updateScheduledJobRule({
projectId,
ruleId: target.id,
scheduledTime,
input,
});
mutated = true;
}
return mutated;
}, [projectId, hasUpcomingReplacement]);
const handleRecurringTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number }) => {
const triggerList = triggersRef.current;
const key = buildTriggerKey(action.config_type, action.name);
const actionChanges = (action.config_changes ?? {}) as Record<string, unknown>;
let mutated = false;
const actionIndex = context?.actionIndex;
const {
createRecurringJobRule,
updateRecurringJobRule,
toggleRecurringJobRule,
deleteRecurringJobRule,
fetchRecurringJobRule,
} = await loadRecurringJobActions();
if (action.action === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
if (pending && pending.type === 'recurring') {
const cron = (actionChanges.cron as string) ?? pending.cron;
const input = coerceTriggerInput(actionChanges.input, pending.input);
if (!cron || !input) {
console.error('Missing data for recurring trigger update via replacement', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: pending.id,
cron,
input,
});
if (hasOwn(actionChanges, 'disabled')) {
const desired = typeof actionChanges.disabled === 'boolean'
? actionChanges.disabled
: pending.disabled;
if (typeof desired === 'boolean' && desired !== pending.disabled) {
await toggleRecurringJobRule({ ruleId: pending.id, disabled: desired });
}
}
pendingTriggerEditsRef.current.delete(key);
mutated = Boolean(updatedRule?.id);
} else {
const cron = actionChanges.cron as string | undefined;
const input = coerceTriggerInput(actionChanges.input);
if (!cron || !input) {
console.error('Missing cron or input for recurring trigger creation', action);
return false;
}
await createRecurringJobRule({
projectId,
cron,
input,
});
mutated = true;
}
return mutated;
}
const target = triggerList.find(
(trigger): trigger is Extract<CopilotTrigger, { type: 'recurring' }> =>
trigger.type === 'recurring' && trigger.name === action.name
);
if (!target) {
console.warn('Unable to resolve recurring trigger for action', action.name);
return false;
}
if (action.action === 'delete') {
if (hasUpcomingReplacement(action, actionIndex)) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteRecurringJobRule({ projectId, ruleId: target.id });
mutated = true;
return mutated;
}
if (action.action === 'edit') {
const existing = await fetchRecurringJobRule({ ruleId: target.id });
if (!existing) {
console.error('Failed to load existing recurring trigger for edit', action.name);
return false;
}
const desiredDisabled = typeof actionChanges.disabled === 'boolean'
? actionChanges.disabled
: existing.disabled;
const hasCronChange = hasOwn(actionChanges, 'cron');
const hasInputChange = hasOwn(actionChanges, 'input');
const hasDisabledToggle = hasOwn(actionChanges, 'disabled');
if (!hasCronChange && !hasInputChange && hasDisabledToggle) {
if (desiredDisabled !== existing.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
}
return true;
}
const cron = (actionChanges.cron as string) ?? existing.cron;
const input = coerceTriggerInput(actionChanges.input, existing.input);
if (!cron || !input) {
console.error('Missing data for recurring trigger edit', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: target.id,
cron,
input,
});
if (hasDisabledToggle && desiredDisabled !== updatedRule.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
}
mutated = true;
}
return mutated;
}, [projectId, hasUpcomingReplacement]);
const handleExternalTrigger = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
if (action.action === 'create_new') {
const actionIndex = context?.actionIndex ?? -1;
const messageIndex = context?.messageIndex ?? -1;
requestTriggerSetup({ action, actionIndex, messageIndex });
return false;
}
if (action.action === 'delete') {
const triggerList = triggersRef.current;
const target = triggerList.find((trigger): trigger is Extract<CopilotTrigger, { type: 'external' }> => {
if (trigger.type !== 'external') {
return false;
}
const maybeName = (trigger as unknown as { name?: string }).name;
return (
trigger.triggerTypeName === action.name ||
trigger.triggerTypeSlug === action.name ||
trigger.id === action.name ||
maybeName === action.name
);
});
if (!target) {
console.warn('Unable to resolve external trigger for action', action.name);
return false;
}
const { deleteComposioTriggerDeployment } = await loadComposioActions();
await deleteComposioTriggerDeployment({ projectId, deploymentId: target.id });
return true;
}
return false;
}, [projectId, requestTriggerSetup]);
const handleTriggerAction = useCallback(async (action: CopilotAction, context?: { actionIndex?: number; messageIndex?: number }) => {
if (action.config_type === 'one_time_trigger') {
const mutated = await handleOneTimeTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
if (action.config_type === 'recurring_trigger') {
const mutated = await handleRecurringTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
if (action.config_type === 'external_trigger') {
const mutated = await handleExternalTrigger(action, context);
if (mutated) {
await refreshTriggers();
}
return mutated;
}
return false;
}, [handleOneTimeTrigger, handleRecurringTrigger, handleExternalTrigger, refreshTriggers]);
return {
triggerSetupModal,
requestTriggerSetup,
closeTriggerSetup,
handleTriggerCreatedViaModal: handleTriggerCreatedViaModal,
handleTriggerAction,
};
}

View file

@ -3,6 +3,7 @@ import { getCopilotResponseStream } from "@/app/actions/copilot.actions";
import { CopilotMessage } from "@/src/entities/models/copilot"; import { CopilotMessage } from "@/src/entities/models/copilot";
import { Workflow } from "@/app/lib/types/workflow_types"; import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source"; import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { z } from "zod"; import { z } from "zod";
import { WithStringId } from "@/app/lib/types/types"; import { WithStringId } from "@/app/lib/types/types";
@ -11,6 +12,7 @@ interface UseCopilotParams {
workflow: z.infer<typeof Workflow>; workflow: z.infer<typeof Workflow>;
context: any; context: any;
dataSources?: z.infer<typeof DataSource>[]; dataSources?: z.infer<typeof DataSource>[];
triggers?: z.infer<typeof TriggerSchemaForCopilot>[];
} }
interface UseCopilotResult { interface UseCopilotResult {
@ -29,7 +31,7 @@ interface UseCopilotResult {
cancel: () => void; cancel: () => void;
} }
export function useCopilot({ projectId, workflow, context, dataSources }: UseCopilotParams): UseCopilotResult { export function useCopilot({ projectId, workflow, context, dataSources, triggers }: UseCopilotParams): UseCopilotResult {
const [streamingResponse, setStreamingResponse] = useState(''); const [streamingResponse, setStreamingResponse] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [toolCalling, setToolCalling] = useState(false); const [toolCalling, setToolCalling] = useState(false);
@ -77,7 +79,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
// Wait 2 rAF frames to let layout stabilize (avoids StrictMode/remount race on initial load) // Wait 2 rAF frames to let layout stabilize (avoids StrictMode/remount race on initial load)
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources); const res = await getCopilotResponseStream(projectId, messages, workflow, context || null, dataSources, triggers);
// Check for billing error // Check for billing error
@ -139,7 +141,7 @@ export function useCopilot({ projectId, workflow, context, dataSources }: UseCop
setLoading(false); setLoading(false);
inFlightRef.current = false; inFlightRef.current = false;
} }
}, [projectId, workflow, context, dataSources]); }, [projectId, workflow, context, dataSources, triggers]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
cancelRef.current?.(); cancelRef.current?.();

View file

@ -1,12 +1,15 @@
'use client'; 'use client';
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { createRecurringJobRule } from "@/app/actions/recurring-job-rules.actions"; import { createRecurringJobRule, updateRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react"; import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
// Define a simpler message type for the form that only includes the fields we need // Define a simpler message type for the form that only includes the fields we need
type FormMessage = { type FormMessage = {
@ -14,6 +17,29 @@ type FormMessage = {
content: string; content: string;
}; };
type BackButtonConfig =
| { label: string; onClick: () => void }
| { label: string; href: string };
type FormSubmitPayload = {
messages: FormMessage[];
cron: string;
};
type RecurringJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialCron?: string;
initialMessages?: FormMessage[];
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const commonCronExamples = [ const commonCronExamples = [
{ label: "Every minute", value: "* * * * *" }, { label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" }, { label: "Every 5 minutes", value: "*/5 * * * *" },
@ -25,86 +51,112 @@ const commonCronExamples = [
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" }, { label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
]; ];
export function CreateRecurringJobRuleForm({ const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
projectId,
onBack, const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
hasExistingTriggers = true if (!messages || messages.length === 0) {
}: { return [createEmptyMessage()];
projectId: string; }
onBack?: () => void;
hasExistingTriggers?: boolean; return messages.map((message) => ({ ...message }));
}) { };
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function RecurringJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialCron,
initialMessages,
onSubmit,
onSuccess,
successHref,
}: RecurringJobRuleFormBaseProps) {
const router = useRouter(); const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [cronExpression, setCronExpression] = useState(initialCron ?? "* * * * *");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
const [cronExpression, setCronExpression] = useState("* * * * *");
const [showCronHelp, setShowCronHelp] = useState(false); const [showCronHelp, setShowCronHelp] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setCronExpression(initialCron ?? "* * * * *");
}, [initialCron]);
const addMessage = () => { const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]); setMessages((prev) => [...prev, createEmptyMessage()]);
}; };
const removeMessage = (index: number) => { const removeMessage = (index: number) => {
if (messages.length > 1) { setMessages((prev) => {
setMessages(messages.filter((_, i) => i !== index)); if (prev.length <= 1) {
} return prev;
}
return prev.filter((_, i) => i !== index);
});
}; };
const updateMessage = (index: number, field: keyof FormMessage, value: string) => { const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages]; setMessages((prev) => {
newMessages[index] = { ...newMessages[index], [field]: value }; const next = [...prev];
setMessages(newMessages); next[index] = { ...next[index], [field]: value };
return next;
});
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Validate required fields
if (!cronExpression.trim()) { if (!cronExpression.trim()) {
alert("Please enter a cron expression"); alert("Please enter a cron expression");
return; return;
} }
if (messages.some(msg => !msg.content?.trim())) { if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content"); alert("Please fill in all message content");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
// Convert FormMessage to the expected Message type const result = await onSubmit({
const convertedMessages = messages.map(msg => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
});
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
cron: cronExpression, cron: cronExpression,
messages,
}); });
if (onBack) {
onBack(); if (onSuccess) {
} else { onSuccess(result);
router.push(`/projects/${projectId}/manage-triggers?tab=recurring`); } else if (successHref) {
router.push(successHref);
} }
} catch (error) { } catch (error) {
console.error("Failed to create recurring job rule:", error); console.error(errorMessage, error);
alert("Failed to create recurring job rule"); alert(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -114,30 +166,39 @@ export function CreateRecurringJobRuleForm({
<Panel <Panel
title={ title={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? ( {backButton ? (
<Button 'onClick' in backButton ? (
variant="secondary" <Button
size="sm" variant="secondary"
startContent={<ArrowLeftIcon className="w-4 h-4" />} size="sm"
className="whitespace-nowrap" startContent={<ArrowLeftIcon className="w-4 h-4" />}
onClick={onBack} className="whitespace-nowrap"
> onClick={backButton.onClick}
Back >
</Button> {backButton.label}
) : hasExistingTriggers ? (
<Link href={`/projects/${projectId}/manage-triggers?tab=recurring`}>
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
Back
</Button> </Button>
</Link> ) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null} ) : null}
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE RECURRING JOB RULE {title}
</div> </div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> {description ? (
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active. <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p> {description}
</p>
) : null}
</div> </div>
</div> </div>
} }
@ -262,7 +323,7 @@ export function CreateRecurringJobRuleForm({
isLoading={loading} isLoading={loading}
className="px-6 py-2 whitespace-nowrap" className="px-6 py-2 whitespace-nowrap"
> >
{loading ? "Creating..." : "Create Rule"} {loading ? submittingLabel : submitLabel}
</Button> </Button>
</div> </div>
</form> </form>
@ -271,3 +332,99 @@ export function CreateRecurringJobRuleForm({
</Panel> </Panel>
); );
} }
export function CreateRecurringJobRuleForm({
projectId,
onBack,
hasExistingTriggers = true,
}: {
projectId: string;
onBack?: () => void;
hasExistingTriggers?: boolean;
}) {
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
cron,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=recurring` }
: undefined;
return (
<RecurringJobRuleFormBase
title="CREATE RECURRING JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create recurring job rule"
backButton={backButton}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=recurring`}
/>
);
}
export function EditRecurringJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof RecurringJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof RecurringJobRule>) => void;
}) {
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
cron,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof RecurringJobRule>);
}
onCancel();
};
return (
<RecurringJobRuleFormBase
title="EDIT RECURRING JOB RULE"
description="Update the cron schedule and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update recurring job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialCron={rule.cron}
initialMessages={initialMessages}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -5,12 +5,13 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions"; import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon } from "lucide-react"; import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon, PencilIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule"; import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { Spinner } from "@heroui/react"; import { Spinner } from "@heroui/react";
import { z } from "zod"; import { z } from "zod";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list"; import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
import { EditRecurringJobRuleForm } from "./create-recurring-job-rule-form";
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) { export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
const router = useRouter(); const router = useRouter();
@ -19,6 +20,7 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
const jobsFilters = useMemo(() => ({ recurringJobRuleId: ruleId }), [ruleId]); const jobsFilters = useMemo(() => ({ recurringJobRuleId: ruleId }), [ruleId]);
@ -145,128 +147,161 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
} }
rightActions={ rightActions={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button {editing ? (
onClick={handleToggleStatus} <Button
disabled={updating} onClick={() => setEditing(false)}
variant={rule.disabled ? "primary" : "secondary"} variant="secondary"
size="sm" size="sm"
isLoading={updating} className="whitespace-nowrap"
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />} >
className="whitespace-nowrap" Cancel Edit
> </Button>
{rule.disabled ? 'Activate' : 'Pause'} ) : (
</Button> <>
<Button <Button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setEditing(true)}
variant="secondary" variant="secondary"
size="sm" size="sm"
startContent={<Trash2Icon className="w-4 h-4" />} startContent={<PencilIcon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap" className="whitespace-nowrap"
> >
Delete Edit
</Button> </Button>
<Button
onClick={handleToggleStatus}
disabled={updating}
variant={rule.disabled ? "primary" : "secondary"}
size="sm"
isLoading={updating}
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{rule.disabled ? 'Activate' : 'Pause'}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div> </div>
} }
> >
<div className="h-full overflow-auto px-4 py-4"> <div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[800px] mx-auto space-y-6"> <div className="max-w-[800px] mx-auto space-y-6">
{/* Status */} {editing ? (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <EditRecurringJobRuleForm
<div className="flex items-center gap-2 mb-2"> projectId={projectId}
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} /> rule={rule}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> onCancel={() => setEditing(false)}
Status: {rule.disabled ? 'Disabled' : 'Active'} onUpdated={(updatedRule) => setRule(updatedRule)}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
/> />
</div> ) : (
<>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
Status: {rule.disabled ? 'Disabled' : 'Active'}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
/>
</div>
</>
)}
</div> </div>
</div> </div>
</Panel> </Panel>

View file

@ -1,132 +1,197 @@
'use client'; 'use client';
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { createScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions"; import { createScheduledJobRule, updateScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react"; import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { DatePicker } from "@heroui/react"; import { DatePicker } from "@heroui/react";
import { ZonedDateTime, now, getLocalTimeZone } from "@internationalized/date"; import { ZonedDateTime, now, getLocalTimeZone, parseAbsoluteToLocal } from "@internationalized/date";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
// Define a simpler message type for the form that only includes the fields we need
type FormMessage = { type FormMessage = {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
content: string; content: string;
}; };
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) { type BackButtonConfig =
const router = useRouter(); | { label: string; onClick: () => void }
const [loading, setLoading] = useState(false); | { label: string; href: string };
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
// Set default to 30 minutes from now with timezone info
const getDefaultDateTime = () => {
const localTimeZone = getLocalTimeZone();
const currentTime = now(localTimeZone);
const thirtyMinutesFromNow = currentTime.add({ minutes: 30 });
return thirtyMinutesFromNow;
};
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(getDefaultDateTime()); type FormSubmitPayload = {
messages: FormMessage[];
scheduledDateTime: ZonedDateTime;
};
type ScheduledJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialMessages?: FormMessage[];
initialDateTime?: ZonedDateTime | null;
placeholderDateTime: ZonedDateTime;
minDateTime: ZonedDateTime;
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
if (!messages || messages.length === 0) {
return [createEmptyMessage()];
}
return messages.map((message) => ({ ...message }));
};
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function ScheduledJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialMessages,
initialDateTime,
placeholderDateTime,
minDateTime,
onSubmit,
onSuccess,
successHref,
}: ScheduledJobRuleFormBaseProps) {
const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(initialDateTime ?? placeholderDateTime);
const [loading, setLoading] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setScheduledDateTime(initialDateTime ?? placeholderDateTime);
}, [initialDateTime, placeholderDateTime]);
const addMessage = () => { const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]); setMessages((prev) => [...prev, createEmptyMessage()]);
}; };
const removeMessage = (index: number) => { const removeMessage = (index: number) => {
if (messages.length > 1) { setMessages((prev) => {
setMessages(messages.filter((_, i) => i !== index)); if (prev.length <= 1) {
} return prev;
}
return prev.filter((_, i) => i !== index);
});
}; };
const updateMessage = (index: number, field: keyof FormMessage, value: string) => { const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages]; setMessages((prev) => {
newMessages[index] = { ...newMessages[index], [field]: value }; const next = [...prev];
setMessages(newMessages); next[index] = { ...next[index], [field]: value };
return next;
});
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Validate required fields
if (!scheduledDateTime) { if (!scheduledDateTime) {
alert("Please select date and time"); alert("Please select date and time");
return; return;
} }
if (messages.some(msg => !msg.content?.trim())) { if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content"); alert("Please fill in all message content");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
// Convert FormMessage to the expected Message type const result = await onSubmit({
const convertedMessages = messages.map(msg => { messages,
if (msg.role === "assistant") { scheduledDateTime,
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
}); });
// Convert ZonedDateTime to ISO string (already in UTC) if (onSuccess) {
const scheduledTimeString = scheduledDateTime.toDate().toISOString(); onSuccess(result);
} else if (successHref) {
await createScheduledJobRule({ router.push(successHref);
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
if (onBack) {
onBack();
} else {
router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`);
} }
} catch (error) { } catch (error) {
console.error("Failed to create scheduled job rule:", error); console.error(errorMessage, error);
alert("Failed to create scheduled job rule"); alert(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<Panel <Panel
title={ title={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? ( {backButton ? (
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap" onClick={onBack}> 'onClick' in backButton ? (
Back <Button
</Button> variant="secondary"
) : hasExistingTriggers ? ( size="sm"
<Link href={`/projects/${projectId}/manage-triggers?tab=scheduled`}> startContent={<ArrowLeftIcon className="w-4 h-4" />}
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap"> className="whitespace-nowrap"
Back onClick={backButton.onClick}
>
{backButton.label}
</Button> </Button>
</Link> ) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null} ) : null}
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE SCHEDULED JOB RULE {title}
</div> </div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> {description ? (
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active. <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p> {description}
</p>
) : null}
</div> </div>
</div> </div>
} }
@ -142,8 +207,8 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
<DatePicker <DatePicker
value={scheduledDateTime} value={scheduledDateTime}
onChange={setScheduledDateTime} onChange={setScheduledDateTime}
placeholderValue={getDefaultDateTime()} placeholderValue={placeholderDateTime}
minValue={now(getLocalTimeZone())} minValue={minDateTime}
granularity="minute" granularity="minute"
isRequired isRequired
className="w-full" className="w-full"
@ -214,7 +279,7 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
isLoading={loading} isLoading={loading}
className="px-6 py-2 whitespace-nowrap" className="px-6 py-2 whitespace-nowrap"
> >
{loading ? "Creating..." : "Create Rule"} {loading ? submittingLabel : submitLabel}
</Button> </Button>
</div> </div>
</form> </form>
@ -223,3 +288,111 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
</Panel> </Panel>
); );
} }
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const minDateTime = useMemo(() => now(timeZone), [timeZone]);
const defaultDateTime = useMemo(() => now(timeZone).add({ minutes: 30 }), [timeZone]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
await createScheduledJobRule({
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=scheduled` }
: undefined;
return (
<ScheduledJobRuleFormBase
title="CREATE SCHEDULED JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create scheduled job rule"
backButton={backButton}
initialDateTime={defaultDateTime}
placeholderDateTime={defaultDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=scheduled`}
/>
);
}
export function EditScheduledJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof ScheduledJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof ScheduledJobRule>) => void;
}) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const initialDateTime = useMemo(() => parseAbsoluteToLocal(rule.nextRunAt), [rule.nextRunAt]);
const nowDateTime = useMemo(() => now(timeZone), [timeZone]);
const minDateTime = useMemo(() => {
return initialDateTime.compare(nowDateTime) < 0 ? initialDateTime : nowDateTime;
}, [initialDateTime, nowDateTime]);
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
const updatedRule = await updateScheduledJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof ScheduledJobRule>);
}
onCancel();
};
return (
<ScheduledJobRuleFormBase
title="EDIT SCHEDULED JOB RULE"
description="Update the scheduled run time and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update scheduled job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialMessages={initialMessages}
initialDateTime={initialDateTime}
placeholderDateTime={initialDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -9,8 +9,9 @@ import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, Trash2Icon } from "lucide-react"; import { ArrowLeftIcon, Trash2Icon, PencilIcon } from "lucide-react";
import { MessageDisplay } from "@/app/lib/components/message-display"; import { MessageDisplay } from "@/app/lib/components/message-display";
import { EditScheduledJobRuleForm } from "./create-scheduled-job-rule-form";
export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) { export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) {
const router = useRouter(); const router = useRouter();
@ -18,6 +19,7 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
@ -92,15 +94,37 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
} }
rightActions={ rightActions={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button {editing ? (
onClick={() => setShowDeleteConfirm(true)} <Button
variant="secondary" onClick={() => setEditing(false)}
size="sm" variant="secondary"
startContent={<Trash2Icon className="w-4 h-4" />} size="sm"
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap" className="whitespace-nowrap"
> >
Delete Cancel Edit
</Button> </Button>
) : (
<>
<Button
onClick={() => setEditing(true)}
variant="secondary"
size="sm"
startContent={<PencilIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
Edit
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div> </div>
} }
> >
@ -114,74 +138,85 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
)} )}
{!loading && rule && ( {!loading && rule && (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Rule Metadata */} {editing ? (
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700"> <EditScheduledJobRuleForm
<div className="grid grid-cols-2 gap-4 text-sm"> projectId={projectId}
<div> rule={rule}
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span> onCancel={() => setEditing(false)}
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span> onUpdated={(updatedRule) => setRule(updatedRule)}
</div> />
<div> ) : (
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span> <>
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}> {/* Rule Metadata */}
{getStatusText(rule.status, rule.processedAt || null)} <div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
</span> <div className="grid grid-cols-2 gap-4 text-sm">
</div> <div>
<div> <span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span> <span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.nextRunAt)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
</div> </div>
)} <div>
{rule.output?.jobId && ( <span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<div> <span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span> {getStatusText(rule.status, rule.processedAt || null)}
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400"> </span>
<Link </div>
href={`/projects/${projectId}/jobs/${rule.output.jobId}`} <div>
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" <span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
> <span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{rule.output.jobId} {formatDateTime(rule.nextRunAt)}
</Link> </span>
</span> </div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
</div>
)}
{rule.output?.jobId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
<Link
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{rule.output.jobId}
</Link>
</span>
</div>
)}
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div> </div>
)} </div>
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div>
</div>
{/* Messages */} {/* Messages */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Messages Messages
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{rule.input.messages.map((message, index) => ( {rule.input.messages.map((message, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700"> <div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<MessageDisplay message={message} index={index} /> <MessageDisplay message={message} index={index} />
</div>
))}
</div> </div>
))} </div>
</div> </>
</div> )}
</div> </div>
)} )}
</div> </div>

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { DataSource } from "@/src/entities/models/data-source"; import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Project } from "@/src/entities/models/project"; import { Project } from "@/src/entities/models/project";
import { z } from "zod"; import { z } from "zod";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -10,10 +11,15 @@ import { revertToLiveWorkflow } from "@/app/actions/project.actions";
import { fetchProject } from "@/app/actions/project.actions"; import { fetchProject } from "@/app/actions/project.actions";
import { Workflow } from "@/app/lib/types/workflow_types"; import { Workflow } from "@/app/lib/types/workflow_types";
import { ModelsResponse } from "@/app/lib/types/billing_types"; import { ModelsResponse } from "@/app/lib/types/billing_types";
import { listScheduledJobRules } from "@/app/actions/scheduled-job-rules.actions";
import { listRecurringJobRules } from "@/app/actions/recurring-job-rules.actions";
import { listComposioTriggerDeployments } from "@/app/actions/composio.actions";
import { transformTriggersForCopilot, DEFAULT_TRIGGER_FETCH_LIMIT } from "./trigger-transform";
export function App({ export function App({
initialProjectData, initialProjectData,
initialDataSources, initialDataSources,
initialTriggers,
eligibleModels, eligibleModels,
useRag, useRag,
useRagUploads, useRagUploads,
@ -24,6 +30,7 @@ export function App({
}: { }: {
initialProjectData: z.infer<typeof Project>; initialProjectData: z.infer<typeof Project>;
initialDataSources: z.infer<typeof DataSource>[]; initialDataSources: z.infer<typeof DataSource>[];
initialTriggers: z.infer<typeof TriggerSchemaForCopilot>[];
eligibleModels: z.infer<typeof ModelsResponse> | "*"; eligibleModels: z.infer<typeof ModelsResponse> | "*";
useRag: boolean; useRag: boolean;
useRagUploads: boolean; useRagUploads: boolean;
@ -44,6 +51,7 @@ export function App({
}); });
const [project, setProject] = useState<z.infer<typeof Project>>(initialProjectData); const [project, setProject] = useState<z.infer<typeof Project>>(initialProjectData);
const [dataSources, setDataSources] = useState<z.infer<typeof DataSource>[]>(initialDataSources); const [dataSources, setDataSources] = useState<z.infer<typeof DataSource>[]>(initialDataSources);
const [triggers, setTriggers] = useState<z.infer<typeof TriggerSchemaForCopilot>[]>(initialTriggers);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
console.log('workflow app.tsx render'); console.log('workflow app.tsx render');
@ -65,21 +73,42 @@ export function App({
workflow = mode === 'live' ? project?.liveWorkflow : project?.draftWorkflow; workflow = mode === 'live' ? project?.liveWorkflow : project?.draftWorkflow;
} }
const reloadData = useCallback(async () => { const fetchTriggers = useCallback(async () => {
setLoading(true); const [scheduled, recurring, composio] = await Promise.all([
const [ listScheduledJobRules({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
projectData, listRecurringJobRules({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
sourcesData, listComposioTriggerDeployments({ projectId: initialProjectData.id, limit: DEFAULT_TRIGGER_FETCH_LIMIT }),
] = await Promise.all([
fetchProject(initialProjectData.id),
listDataSources(initialProjectData.id),
]); ]);
setProject(projectData); return transformTriggersForCopilot({
setDataSources(sourcesData); scheduled: scheduled.items ?? [],
setLoading(false); recurring: recurring.items ?? [],
composio: composio.items ?? [],
});
}, [initialProjectData.id]); }, [initialProjectData.id]);
const refreshTriggers = useCallback(async () => {
const nextTriggers = await fetchTriggers();
setTriggers(nextTriggers);
}, [fetchTriggers]);
const reloadData = useCallback(async () => {
setLoading(true);
try {
const [projectData, sourcesData, triggerData] = await Promise.all([
fetchProject(initialProjectData.id),
listDataSources(initialProjectData.id),
fetchTriggers(),
]);
setProject(projectData);
setDataSources(sourcesData);
setTriggers(triggerData);
} finally {
setLoading(false);
}
}, [fetchTriggers, initialProjectData.id]);
const handleProjectToolsUpdate = useCallback(async () => { const handleProjectToolsUpdate = useCallback(async () => {
// Lightweight refresh for tool-only updates // Lightweight refresh for tool-only updates
const projectConfig = await fetchProject(initialProjectData.id); const projectConfig = await fetchProject(initialProjectData.id);
@ -133,8 +162,12 @@ export function App({
async function handleRevertToLive() { async function handleRevertToLive() {
setLoading(true); setLoading(true);
await revertToLiveWorkflow(initialProjectData.id); try {
reloadData(); await revertToLiveWorkflow(initialProjectData.id);
await reloadData();
} finally {
setLoading(false);
}
} }
// if workflow is null, show the selector // if workflow is null, show the selector
@ -152,6 +185,7 @@ export function App({
onToggleAutoPublish={handleToggleAutoPublish} onToggleAutoPublish={handleToggleAutoPublish}
workflow={workflow} workflow={workflow}
dataSources={dataSources} dataSources={dataSources}
triggers={triggers}
projectConfig={project} projectConfig={project}
useRag={useRag} useRag={useRag}
useRagUploads={useRagUploads} useRagUploads={useRagUploads}
@ -164,6 +198,7 @@ export function App({
onProjectToolsUpdated={handleProjectToolsUpdate} onProjectToolsUpdated={handleProjectToolsUpdate}
onDataSourcesUpdated={handleDataSourcesUpdate} onDataSourcesUpdated={handleDataSourcesUpdate}
onProjectConfigUpdated={handleProjectConfigUpdate} onProjectConfigUpdated={handleProjectConfigUpdate}
onTriggersUpdated={refreshTriggers}
chatWidgetHost={chatWidgetHost} chatWidgetHost={chatWidgetHost}
/>} />}
</> </>

View file

@ -13,6 +13,7 @@ interface ComposioTriggerTypesPanelProps {
toolkit: z.infer<typeof ZToolkit>; toolkit: z.infer<typeof ZToolkit>;
onBack: () => void; onBack: () => void;
onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void; onSelectTriggerType: (triggerType: z.infer<typeof ComposioTriggerType>) => void;
initialTriggerTypeSlug?: string | null;
} }
type TriggerType = z.infer<typeof ComposioTriggerType>; type TriggerType = z.infer<typeof ComposioTriggerType>;
@ -21,6 +22,7 @@ export function ComposioTriggerTypesPanel({
toolkit, toolkit,
onBack, onBack,
onSelectTriggerType, onSelectTriggerType,
initialTriggerTypeSlug,
}: ComposioTriggerTypesPanelProps) { }: ComposioTriggerTypesPanelProps) {
const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]); const [triggerTypes, setTriggerTypes] = useState<TriggerType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -28,6 +30,7 @@ export function ComposioTriggerTypesPanel({
const [cursor, setCursor] = useState<string | null>(null); const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false); const [hasNextPage, setHasNextPage] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [autoSelected, setAutoSelected] = useState(false);
const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => { const loadTriggerTypes = useCallback(async (resetList = false, nextCursor?: string) => {
try { try {
@ -70,8 +73,20 @@ export function ComposioTriggerTypesPanel({
useEffect(() => { useEffect(() => {
loadTriggerTypes(true); loadTriggerTypes(true);
setAutoSelected(false);
}, [loadTriggerTypes]); }, [loadTriggerTypes]);
useEffect(() => {
if (!initialTriggerTypeSlug || autoSelected || triggerTypes.length === 0) {
return;
}
const match = triggerTypes.find(triggerType => triggerType.slug === initialTriggerTypeSlug);
if (match) {
setAutoSelected(true);
onSelectTriggerType(match);
}
}, [initialTriggerTypeSlug, triggerTypes, onSelectTriggerType, autoSelected]);
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -215,4 +230,4 @@ export function ComposioTriggerTypesPanel({
)} )}
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react'; import { Button, Input, Card, CardBody, CardHeader } from '@heroui/react';
import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react'; import { ArrowLeft, ZapIcon, CheckCircleIcon } from 'lucide-react';
import { z } from 'zod'; import { z } from 'zod';
@ -13,6 +13,7 @@ interface TriggerConfigFormProps {
onBack: () => void; onBack: () => void;
onSubmit: (config: Record<string, unknown>) => void; onSubmit: (config: Record<string, unknown>) => void;
isSubmitting?: boolean; isSubmitting?: boolean;
initialConfig?: Record<string, unknown>;
} }
interface JsonSchemaProperty { interface JsonSchemaProperty {
@ -36,13 +37,36 @@ export function TriggerConfigForm({
onBack, onBack,
onSubmit, onSubmit,
isSubmitting = false, isSubmitting = false,
initialConfig,
}: TriggerConfigFormProps) { }: TriggerConfigFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({}); const [formData, setFormData] = useState<Record<string, string>>(() => {
if (!initialConfig) {
return {};
}
return Object.entries(initialConfig).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {});
});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
// Parse the JSON schema from triggerType.config // Parse the JSON schema from triggerType.config
const schema = triggerType.config as JsonSchema; const schema = triggerType.config as JsonSchema;
useEffect(() => {
if (!initialConfig) {
return;
}
setFormData(Object.entries(initialConfig).reduce<Record<string, string>>((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {}));
}, [initialConfig, triggerType.slug]);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
// Validate required fields // Validate required fields
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
@ -267,4 +291,4 @@ export function TriggerConfigForm({
</div> </div>
</div> </div>
); );
} }

View file

@ -9,10 +9,17 @@ import { ModelsResponse } from "@/app/lib/types/billing_types";
import { requireAuth } from "@/app/lib/auth"; import { requireAuth } from "@/app/lib/auth";
import { IFetchProjectController } from "@/src/interface-adapters/controllers/projects/fetch-project.controller"; import { IFetchProjectController } from "@/src/interface-adapters/controllers/projects/fetch-project.controller";
import { IListDataSourcesController } from "@/src/interface-adapters/controllers/data-sources/list-data-sources.controller"; import { IListDataSourcesController } from "@/src/interface-adapters/controllers/data-sources/list-data-sources.controller";
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { z } from "zod"; import { z } from "zod";
import { transformTriggersForCopilot, DEFAULT_TRIGGER_FETCH_LIMIT } from "./trigger-transform";
const fetchProjectController = container.resolve<IFetchProjectController>('fetchProjectController'); const fetchProjectController = container.resolve<IFetchProjectController>('fetchProjectController');
const listDataSourcesController = container.resolve<IListDataSourcesController>('listDataSourcesController'); const listDataSourcesController = container.resolve<IListDataSourcesController>('listDataSourcesController');
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const listRecurringJobRulesController = container.resolve<IListRecurringJobRulesController>('listRecurringJobRulesController');
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>('listComposioTriggerDeploymentsController');
const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1"; const DEFAULT_MODEL = process.env.PROVIDER_DEFAULT_MODEL || "gpt-4.1";
@ -39,23 +46,50 @@ export default async function Page(
notFound(); notFound();
} }
const sources = await listDataSourcesController.execute({ const [sources, scheduledTriggers, recurringTriggers, composioTriggers] = await Promise.all([
caller: "user", listDataSourcesController.execute({
userId: user.id, caller: "user",
projectId: params.projectId, userId: user.id,
}); projectId: params.projectId,
}),
listScheduledJobRulesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
listRecurringJobRulesController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
listComposioTriggerDeploymentsController.execute({
caller: "user",
userId: user.id,
projectId: params.projectId,
limit: DEFAULT_TRIGGER_FETCH_LIMIT,
}),
]);
let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*'; let eligibleModels: z.infer<typeof ModelsResponse> | "*" = '*';
if (USE_BILLING) { if (USE_BILLING) {
eligibleModels = await getEligibleModels(customer.id); eligibleModels = await getEligibleModels(customer.id);
} }
const triggers = transformTriggersForCopilot({
scheduled: scheduledTriggers.items ?? [],
recurring: recurringTriggers.items ?? [],
composio: composioTriggers.items ?? [],
});
console.log('/workflow page.tsx serve'); console.log('/workflow page.tsx serve');
return ( return (
<App <App
initialProjectData={project} initialProjectData={project}
initialDataSources={sources} initialDataSources={sources}
initialTriggers={triggers}
eligibleModels={eligibleModels} eligibleModels={eligibleModels}
useRag={USE_RAG} useRag={USE_RAG}
useRagUploads={USE_RAG_UPLOADS} useRagUploads={USE_RAG_UPLOADS}

View file

@ -0,0 +1,78 @@
import { z } from "zod";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Message } from "@/app/lib/types/types";
const COPILOT_TRIGGER_LIMIT = 100;
export const DEFAULT_TRIGGER_FETCH_LIMIT = COPILOT_TRIGGER_LIMIT;
export type CopilotTrigger = z.infer<typeof TriggerSchemaForCopilot>;
interface TransformParams {
scheduled: Array<{
id: string;
nextRunAt: string;
status: 'pending' | 'processing' | 'triggered';
input?: { messages: Array<z.infer<typeof Message>> };
}>;
recurring: Array<{
id: string;
cron: string;
nextRunAt: string | null;
disabled: boolean;
input?: { messages: Array<z.infer<typeof Message>> };
}>;
composio: Array<{
id: string;
triggerTypeName: string;
toolkitSlug: string;
triggerTypeSlug: string;
triggerConfig: Record<string, unknown>;
}>;
}
export function transformTriggersForCopilot({
scheduled,
recurring,
composio,
}: TransformParams): CopilotTrigger[] {
const placeholderInput = {
messages: [
{
role: "user" as const,
content: "Trigger execution",
},
],
} satisfies { messages: Array<z.infer<typeof Message>> };
const oneTime = scheduled.map((trigger) => ({
type: "one_time" as const,
id: trigger.id,
name: `One-time trigger (${new Date(trigger.nextRunAt).toLocaleDateString('en-US')})`,
nextRunAt: trigger.nextRunAt,
status: trigger.status,
input: trigger.input ?? placeholderInput,
}));
const recurringTriggers = recurring.map((trigger) => ({
type: "recurring" as const,
id: trigger.id,
name: `Recurring trigger (${trigger.cron})`,
cron: trigger.cron,
nextRunAt: trigger.nextRunAt ?? '',
disabled: trigger.disabled,
input: trigger.input ?? placeholderInput,
}));
const external = composio.map((trigger) => ({
type: "external" as const,
id: trigger.id,
name: trigger.triggerTypeName,
triggerTypeName: trigger.triggerTypeName,
toolkitSlug: trigger.toolkitSlug,
triggerTypeSlug: trigger.triggerTypeSlug,
triggerConfig: trigger.triggerConfig,
}));
return [...oneTime, ...recurringTriggers, ...external] as CopilotTrigger[];
}

View file

@ -3,6 +3,7 @@ import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, c
import { MCPServer, Message, WithStringId } from "../../../lib/types/types"; import { MCPServer, Message, WithStringId } from "../../../lib/types/types";
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent, WorkflowPipeline } from "../../../lib/types/workflow_types"; import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent, WorkflowPipeline } from "../../../lib/types/workflow_types";
import { DataSource } from "@/src/entities/models/data-source"; import { DataSource } from "@/src/entities/models/data-source";
import { TriggerSchemaForCopilot } from "@/src/entities/models/copilot";
import { Project } from "@/src/entities/models/project"; import { Project } from "@/src/entities/models/project";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer'; import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "../entities/agent_config"; import { AgentConfig } from "../entities/agent_config";
@ -962,6 +963,7 @@ export function useEntitySelection() {
export function WorkflowEditor({ export function WorkflowEditor({
projectId, projectId,
dataSources, dataSources,
triggers,
workflow, workflow,
useRag, useRag,
useRagUploads, useRagUploads,
@ -978,10 +980,12 @@ export function WorkflowEditor({
onProjectToolsUpdated, onProjectToolsUpdated,
onDataSourcesUpdated, onDataSourcesUpdated,
onProjectConfigUpdated, onProjectConfigUpdated,
onTriggersUpdated,
chatWidgetHost, chatWidgetHost,
}: { }: {
projectId: string; projectId: string;
dataSources: z.infer<typeof DataSource>[]; dataSources: z.infer<typeof DataSource>[];
triggers: z.infer<typeof TriggerSchemaForCopilot>[];
workflow: z.infer<typeof Workflow>; workflow: z.infer<typeof Workflow>;
useRag: boolean; useRag: boolean;
useRagUploads: boolean; useRagUploads: boolean;
@ -998,6 +1002,7 @@ export function WorkflowEditor({
onProjectToolsUpdated?: () => void; onProjectToolsUpdated?: () => void;
onDataSourcesUpdated?: () => void; onDataSourcesUpdated?: () => void;
onProjectConfigUpdated?: () => void; onProjectConfigUpdated?: () => void;
onTriggersUpdated?: () => Promise<void> | void;
chatWidgetHost: string; chatWidgetHost: string;
}) { }) {
@ -2313,8 +2318,10 @@ export function WorkflowEditor({
} }
isInitialState={isInitialState} isInitialState={isInitialState}
dataSources={dataSources} dataSources={dataSources}
triggers={triggers}
activePanel={activePanel} activePanel={activePanel}
onTogglePanel={handleTogglePanel} onTogglePanel={handleTogglePanel}
onTriggersUpdated={onTriggersUpdated}
/> />
{/* Config overlay above Copilot when agents + skipper layout is active */} {/* Config overlay above Copilot when agents + skipper layout is active */}
{state.present.selection && viewMode === 'two_agents_skipper' && ( {state.present.selection && viewMode === 'two_agents_skipper' && (

View file

@ -268,7 +268,7 @@ function CopilotStatusBar({
// Show real button when ready // Show real button when ready
return ( return (
<button <button
onClick={handleApplyAll} onClick={() => { void handleApplyAll?.(); }}
disabled={allApplied} disabled={allApplied}
className={`flex items-center gap-2 px-3 py-1 rounded-full font-medium text-xs transition-colors duration-200 className={`flex items-center gap-2 px-3 py-1 rounded-full font-medium text-xs transition-colors duration-200
${ ${

View file

@ -73,10 +73,12 @@ import { CreateScheduledJobRuleUseCase } from "@/src/application/use-cases/sched
import { FetchScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/fetch-scheduled-job-rule.use-case"; import { FetchScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/fetch-scheduled-job-rule.use-case";
import { ListScheduledJobRulesUseCase } from "@/src/application/use-cases/scheduled-job-rules/list-scheduled-job-rules.use-case"; import { ListScheduledJobRulesUseCase } from "@/src/application/use-cases/scheduled-job-rules/list-scheduled-job-rules.use-case";
import { DeleteScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/delete-scheduled-job-rule.use-case"; import { DeleteScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/delete-scheduled-job-rule.use-case";
import { UpdateScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case";
import { CreateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/create-scheduled-job-rule.controller"; import { CreateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/create-scheduled-job-rule.controller";
import { FetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller"; import { FetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { ListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller"; import { ListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { DeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller"; import { DeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { UpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller";
// Recurring Job Rules // Recurring Job Rules
import { MongoDBRecurringJobRulesRepository } from "@/src/infrastructure/repositories/mongodb.recurring-job-rules.repository"; import { MongoDBRecurringJobRulesRepository } from "@/src/infrastructure/repositories/mongodb.recurring-job-rules.repository";
@ -85,11 +87,13 @@ import { FetchRecurringJobRuleUseCase } from "@/src/application/use-cases/recurr
import { ListRecurringJobRulesUseCase } from "@/src/application/use-cases/recurring-job-rules/list-recurring-job-rules.use-case"; import { ListRecurringJobRulesUseCase } from "@/src/application/use-cases/recurring-job-rules/list-recurring-job-rules.use-case";
import { ToggleRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/toggle-recurring-job-rule.use-case"; import { ToggleRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/toggle-recurring-job-rule.use-case";
import { DeleteRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/delete-recurring-job-rule.use-case"; import { DeleteRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/delete-recurring-job-rule.use-case";
import { UpdateRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case";
import { CreateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/create-recurring-job-rule.controller"; import { CreateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/create-recurring-job-rule.controller";
import { FetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller"; import { FetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { ListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller"; import { ListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller"; import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller"; import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { UpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller";
// API Keys // API Keys
import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case"; import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
@ -238,10 +242,12 @@ container.register({
createScheduledJobRuleUseCase: asClass(CreateScheduledJobRuleUseCase).singleton(), createScheduledJobRuleUseCase: asClass(CreateScheduledJobRuleUseCase).singleton(),
fetchScheduledJobRuleUseCase: asClass(FetchScheduledJobRuleUseCase).singleton(), fetchScheduledJobRuleUseCase: asClass(FetchScheduledJobRuleUseCase).singleton(),
listScheduledJobRulesUseCase: asClass(ListScheduledJobRulesUseCase).singleton(), listScheduledJobRulesUseCase: asClass(ListScheduledJobRulesUseCase).singleton(),
updateScheduledJobRuleUseCase: asClass(UpdateScheduledJobRuleUseCase).singleton(),
deleteScheduledJobRuleUseCase: asClass(DeleteScheduledJobRuleUseCase).singleton(), deleteScheduledJobRuleUseCase: asClass(DeleteScheduledJobRuleUseCase).singleton(),
createScheduledJobRuleController: asClass(CreateScheduledJobRuleController).singleton(), createScheduledJobRuleController: asClass(CreateScheduledJobRuleController).singleton(),
fetchScheduledJobRuleController: asClass(FetchScheduledJobRuleController).singleton(), fetchScheduledJobRuleController: asClass(FetchScheduledJobRuleController).singleton(),
listScheduledJobRulesController: asClass(ListScheduledJobRulesController).singleton(), listScheduledJobRulesController: asClass(ListScheduledJobRulesController).singleton(),
updateScheduledJobRuleController: asClass(UpdateScheduledJobRuleController).singleton(),
deleteScheduledJobRuleController: asClass(DeleteScheduledJobRuleController).singleton(), deleteScheduledJobRuleController: asClass(DeleteScheduledJobRuleController).singleton(),
// recurring job rules // recurring job rules
@ -251,11 +257,13 @@ container.register({
fetchRecurringJobRuleUseCase: asClass(FetchRecurringJobRuleUseCase).singleton(), fetchRecurringJobRuleUseCase: asClass(FetchRecurringJobRuleUseCase).singleton(),
listRecurringJobRulesUseCase: asClass(ListRecurringJobRulesUseCase).singleton(), listRecurringJobRulesUseCase: asClass(ListRecurringJobRulesUseCase).singleton(),
toggleRecurringJobRuleUseCase: asClass(ToggleRecurringJobRuleUseCase).singleton(), toggleRecurringJobRuleUseCase: asClass(ToggleRecurringJobRuleUseCase).singleton(),
updateRecurringJobRuleUseCase: asClass(UpdateRecurringJobRuleUseCase).singleton(),
deleteRecurringJobRuleUseCase: asClass(DeleteRecurringJobRuleUseCase).singleton(), deleteRecurringJobRuleUseCase: asClass(DeleteRecurringJobRuleUseCase).singleton(),
createRecurringJobRuleController: asClass(CreateRecurringJobRuleController).singleton(), createRecurringJobRuleController: asClass(CreateRecurringJobRuleController).singleton(),
fetchRecurringJobRuleController: asClass(FetchRecurringJobRuleController).singleton(), fetchRecurringJobRuleController: asClass(FetchRecurringJobRuleController).singleton(),
listRecurringJobRulesController: asClass(ListRecurringJobRulesController).singleton(), listRecurringJobRulesController: asClass(ListRecurringJobRulesController).singleton(),
toggleRecurringJobRuleController: asClass(ToggleRecurringJobRuleController).singleton(), toggleRecurringJobRuleController: asClass(ToggleRecurringJobRuleController).singleton(),
updateRecurringJobRuleController: asClass(UpdateRecurringJobRuleController).singleton(),
deleteRecurringJobRuleController: asClass(DeleteRecurringJobRuleController).singleton(), deleteRecurringJobRuleController: asClass(DeleteRecurringJobRuleController).singleton(),
// projects // projects
@ -344,4 +352,4 @@ container.register({
// users // users
// --- // ---
usersRepository: asClass(MongoDBUsersRepository).singleton(), usersRepository: asClass(MongoDBUsersRepository).singleton(),
}); });

View file

@ -2,7 +2,7 @@ import z from "zod";
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
import { generateObject, streamText, tool } from "ai"; import { generateObject, streamText, tool } from "ai";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types"; import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from "../../../entities/models/copilot"; import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from "../../../entities/models/copilot";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import zodToJsonSchema from "zod-to-json-schema"; import zodToJsonSchema from "zod-to-json-schema";
import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent"; import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent";
@ -10,7 +10,7 @@ import { COPILOT_INSTRUCTIONS_MULTI_AGENT_WITH_DOCS as COPILOT_INSTRUCTIONS_MULT
import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1"; import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1";
import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow"; import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow";
import { USE_COMPOSIO_TOOLS } from "@/app/lib/feature_flags"; import { USE_COMPOSIO_TOOLS } from "@/app/lib/feature_flags";
import { composio, getTool } from "../composio/composio"; import { composio, getTool, listTriggersTypes } from "../composio/composio";
import { UsageTracker } from "@/app/lib/billing"; import { UsageTracker } from "@/app/lib/billing";
import { CopilotStreamEvent } from "@/src/entities/models/copilot"; import { CopilotStreamEvent } from "@/src/entities/models/copilot";
@ -98,6 +98,55 @@ ${JSON.stringify(simplifiedDataSources)}
return prompt; return prompt;
} }
function getCurrentTimePrompt(): string {
return `**CURRENT TIME**: ${new Date().toISOString()}`;
}
function getTriggersPrompt(triggers: z.infer<typeof TriggerSchemaForCopilot>[]): string {
if (!triggers || triggers.length === 0) {
return '';
}
const simplifiedTriggers = triggers.map(trigger => {
if (trigger.type === 'one_time') {
return {
id: trigger.id,
type: 'one_time',
name: trigger.name,
scheduledTime: trigger.nextRunAt,
input: trigger.input,
status: trigger.status,
};
} else if (trigger.type === 'recurring') {
return {
id: trigger.id,
type: 'recurring',
name: trigger.name,
cron: trigger.cron,
nextRunAt: trigger.nextRunAt,
disabled: trigger.disabled,
input: trigger.input,
};
} else {
return {
id: trigger.id,
type: 'external',
name: trigger.triggerTypeName,
toolkit: trigger.toolkitSlug,
triggerType: trigger.triggerTypeSlug,
config: trigger.triggerConfig,
};
}
});
return `**NOTE**:
The following triggers are currently configured:
\`\`\`json
${JSON.stringify(simplifiedTriggers)}
\`\`\`
`;
}
async function searchRelevantTools(usageTracker: UsageTracker, query: string): Promise<string> { async function searchRelevantTools(usageTracker: UsageTracker, query: string): Promise<string> {
const logger = new PrefixLogger("copilot-search-tools"); const logger = new PrefixLogger("copilot-search-tools");
console.log("🔧 TOOL CALL: searchRelevantTools", { query }); console.log("🔧 TOOL CALL: searchRelevantTools", { query });
@ -185,15 +234,107 @@ async function searchRelevantTools(usageTracker: UsageTracker, query: string): P
return response; return response;
} }
async function searchRelevantTriggers(
usageTracker: UsageTracker,
toolkitSlug: string,
query?: string,
): Promise<string> {
const logger = new PrefixLogger("copilot-search-triggers");
const trimmedSlug = toolkitSlug.trim();
const trimmedQuery = query?.trim() || '';
console.log("🔧 TOOL CALL: searchRelevantTriggers", { toolkitSlug: trimmedSlug, query: trimmedQuery });
if (!trimmedSlug) {
logger.log('no toolkit slug provided');
return 'Please provide a toolkit slug (for example "gmail" or "slack") when searching for triggers.';
}
if (!USE_COMPOSIO_TOOLS) {
logger.log('dynamic trigger search is disabled');
console.log("❌ TOOL CALL SKIPPED: searchRelevantTriggers - Composio tools disabled");
return 'Trigger search is currently unavailable.';
}
const MAX_PAGES = 5;
type TriggerListResponse = Awaited<ReturnType<typeof listTriggersTypes>>;
type TriggerType = TriggerListResponse['items'][number];
const triggers: TriggerType[] = [];
let cursor: string | undefined;
try {
for (let page = 0; page < MAX_PAGES; page++) {
logger.log(`fetching trigger page ${page + 1} for toolkit ${trimmedSlug}`);
console.log("🔍 TOOL CALL: COMPOSIO_LIST_TRIGGERS", { toolkitSlug: trimmedSlug, cursor });
const response = await listTriggersTypes(trimmedSlug, cursor);
triggers.push(...response.items);
console.log("✅ TOOL CALL SUCCESS: COMPOSIO_LIST_TRIGGERS", {
toolkitSlug: trimmedSlug,
fetchedCount: response.items.length,
totalCollected: triggers.length,
hasNext: Boolean(response.next_cursor),
});
if (!response.next_cursor) {
break;
}
cursor = response.next_cursor || undefined;
}
} catch (error: any) {
logger.log(`trigger search failed: ${error?.message || error}`);
console.log("❌ TOOL CALL FAILED: COMPOSIO_LIST_TRIGGERS", {
toolkitSlug: trimmedSlug,
error: error?.message || error,
});
return `Trigger search failed for toolkit "${trimmedSlug}".`;
}
usageTracker.track({
type: "COMPOSIO_TOOL_USAGE",
toolSlug: `COMPOSIO_LIST_TRIGGER_TYPES:${trimmedSlug}`,
context: "copilot.search_relevant_triggers",
});
if (!triggers.length) {
logger.log('no triggers found for toolkit');
return `No triggers are currently available for toolkit "${trimmedSlug}".`;
}
const MAX_RESULTS = 8;
const limitedTriggers = triggers.slice(0, MAX_RESULTS);
const truncated = triggers.length > limitedTriggers.length;
const formattedTriggers = limitedTriggers.map(trigger => {
const requiredFields = trigger.config.required && trigger.config.required.length
? trigger.config.required.join(', ')
: 'None';
const configJson = JSON.stringify(trigger.config, null, 2);
return `**${trigger.name}** (slug: ${trigger.slug})\nToolkit: ${trigger.toolkit.name} (${trigger.toolkit.slug})\nDescription: ${trigger.description}\nRequired config fields: ${requiredFields}\n\`\`\`json\n${configJson}\n\`\`\``;
}).join('\n\n');
const header = trimmedQuery
? `Available triggers for toolkit "${trimmedSlug}" (user query: "${trimmedQuery}"):`
: `Available triggers for toolkit "${trimmedSlug}":`;
const note = truncated
? `\n\nOnly showing the first ${MAX_RESULTS} results out of ${triggers.length}. The toolkit has more triggers available.`
: '';
const response = `${header}\n\n${formattedTriggers}${note}`;
logger.log('returning trigger search response');
return response;
}
function updateLastUserMessage( function updateLastUserMessage(
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
currentWorkflowPrompt: string, currentWorkflowPrompt: string,
contextPrompt: string, contextPrompt: string,
dataSourcesPrompt: string = '', dataSourcesPrompt: string = '',
timePrompt: string = '',
triggersPrompt: string = '',
): void { ): void {
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
if (lastMessage.role === 'user') { if (lastMessage.role === 'user') {
lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`; lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\n${timePrompt}\n\n${triggersPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`;
} }
} }
@ -203,6 +344,7 @@ export async function getEditAgentInstructionsResponse(
context: z.infer<typeof CopilotChatContext> | null, context: z.infer<typeof CopilotChatContext> | null,
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
triggers: z.infer<typeof TriggerSchemaForCopilot>[] = [],
): Promise<string> { ): Promise<string> {
const logger = new PrefixLogger('copilot /getUpdatedAgentInstructions'); const logger = new PrefixLogger('copilot /getUpdatedAgentInstructions');
logger.log('context', context); logger.log('context', context);
@ -214,8 +356,14 @@ export async function getEditAgentInstructionsResponse(
// set context prompt // set context prompt
let contextPrompt = getContextPrompt(context); let contextPrompt = getContextPrompt(context);
// set time prompt
let timePrompt = getCurrentTimePrompt();
// set triggers prompt
let triggersPrompt = getTriggersPrompt(triggers);
// add the above prompts to the last user message // add the above prompts to the last user message
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt); updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, '', timePrompt, triggersPrompt);
// call model // call model
console.log("calling model", JSON.stringify({ console.log("calling model", JSON.stringify({
@ -255,7 +403,8 @@ export async function* streamMultiAgentResponse(
context: z.infer<typeof CopilotChatContext> | null, context: z.infer<typeof CopilotChatContext> | null,
messages: z.infer<typeof CopilotMessage>[], messages: z.infer<typeof CopilotMessage>[],
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
dataSources: z.infer<typeof DataSourceSchemaForCopilot>[] dataSources: z.infer<typeof DataSourceSchemaForCopilot>[],
triggers: z.infer<typeof TriggerSchemaForCopilot>[] = []
): AsyncIterable<z.infer<typeof CopilotStreamEvent>> { ): AsyncIterable<z.infer<typeof CopilotStreamEvent>> {
const logger = new PrefixLogger('copilot /stream'); const logger = new PrefixLogger('copilot /stream');
logger.log('context', context); logger.log('context', context);
@ -277,14 +426,20 @@ export async function* streamMultiAgentResponse(
// set data sources prompt // set data sources prompt
let dataSourcesPrompt = getDataSourcesPrompt(dataSources); let dataSourcesPrompt = getDataSourcesPrompt(dataSources);
// set time prompt
let timePrompt = getCurrentTimePrompt();
// set triggers prompt
let triggersPrompt = getTriggersPrompt(triggers);
// add the above prompts to the last user message // add the above prompts to the last user message
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt); updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt, timePrompt, triggersPrompt);
// call model // call model
console.log("🤖 AI MODEL CALL STARTED", { console.log("🤖 AI MODEL CALL STARTED", {
model: COPILOT_MODEL, model: COPILOT_MODEL,
maxSteps: 20, maxSteps: 20,
availableTools: ["search_relevant_tools"] availableTools: ["search_relevant_tools", "search_relevant_triggers"]
}); });
const { fullStream } = streamText({ const { fullStream } = streamText({
@ -306,6 +461,23 @@ export async function* streamMultiAgentResponse(
return result; return result;
}, },
}), }),
"search_relevant_triggers": tool({
description: "Use this tool to discover external triggers provided by Composio toolkits. Supply the toolkit slug (for example 'gmail', 'slack', or 'salesforce') and optionally keywords from the user's request to narrow down results. Always call this before adding an external trigger to ensure the trigger exists and to understand its configuration schema.",
parameters: z.object({
toolkitSlug: z.string().describe("Slug of the Composio toolkit to search, such as 'gmail', 'slack', 'salesforce', 'googlecalendar'."),
query: z.string().min(1).describe("Optional keywords pulled from the user's request to filter trigger names, descriptions, or config fields.").optional(),
}),
execute: async ({ toolkitSlug, query }: { toolkitSlug: string; query?: string }) => {
console.log("🎯 AI TOOL CALL: search_relevant_triggers", { toolkitSlug, query });
const result = await searchRelevantTriggers(usageTracker, toolkitSlug, query);
console.log("✅ AI TOOL CALL COMPLETED: search_relevant_triggers", {
toolkitSlug,
query,
resultLength: result.length,
});
return result;
},
}),
}, },
messages: [ messages: [
{ {

View file

@ -13,6 +13,8 @@ You can perform the following tasks:
5. Add, edit, or remove tools 5. Add, edit, or remove tools
6. Adding RAG data sources to agents 6. Adding RAG data sources to agents
7. Create and manage pipelines (sequential agent workflows) 7. Create and manage pipelines (sequential agent workflows)
8. Create One-Time Triggers (scheduled to run once at a specific time)
9. Create Recurring Triggers (scheduled to run repeatedly using cron expressions)
Always aim to fully resolve the user's query before yielding. Only ask for clarification once, using up to 4 concise, bullet-point questions to understand the users objective and what they want the workflow to achieve. Always aim to fully resolve the user's query before yielding. Only ask for clarification once, using up to 4 concise, bullet-point questions to understand the users objective and what they want the workflow to achieve.
@ -193,6 +195,197 @@ Note: The agents have access to a tool called 'Generate Image'. This won't show
</agent_tools> </agent_tools>
<about_triggers>
## Section: Creating Triggers
Triggers are automated mechanisms that activate your agents at specific times or intervals. Evaluate every user request for automation or event driven tasks. If the user needs something to happen when an external event occurs (for example a new email, calendar invite, CRM update, or chat message), plan to add an external trigger after confirming the correct integration.
IMPORTANT: External triggers cannot be edited once created. If the user wants to change an external trigger, you must explain that the only option is to delete the existing trigger and create a new one with the updated configuration. Always offer to perform the delete-and-recreate workflow for them.
### Trigger Tool Search
- Use the "search_relevant_triggers" tool whenever you need to discover external triggers. Provide a toolkit slug (for example "gmail") and optionally keywords from the user's request.
- Do not invent trigger names. Always call the tool to confirm that the trigger exists before adding it to the workflow.
### CRITICAL: External Trigger Creation Flow
When a user asks to add an external trigger (e.g., "add Gmail trigger", "trigger on new Google Sheets row", "watch for Slack messages"):
1. **DO NOT ask for configuration details** in the chat. The user will configure the trigger in the UI after authentication.
2. **Immediately create** an "external_trigger" action with minimal/default configuration fields.
3. **Present the trigger card** with an "Open setup" button so the user can authenticate and configure it in the UI.
4. **Keep your response brief**: Just mention what trigger you're adding and that they'll configure it via the setup button.
Example response pattern:
"I'll add the [Trigger Name] trigger. Once you review and click 'Open setup', you can authenticate and configure the specific details like [brief mention of key fields]."
**DO NOT** engage in back-and-forth asking for spreadsheet IDs, sheet names, or other configuration values in chat. These are collected through the UI setup flow after the trigger card is created.
### Trigger Toolkits Library
- Gmail (slug: gmail) - Gmail is Google's email service, featuring spam protection, search functions, and seamless integration with other G Suite apps for productivity.
- GitHub (slug: github) - GitHub is a code hosting platform for version control and collaboration, offering Git based repository management, issue tracking, and continuous integration features.
- Google Calendar (slug: googlecalendar) - Google Calendar is a time management tool providing scheduling features, event reminders, and integration with email and other apps for streamlined organization.
- Notion (slug: notion) - Notion centralizes notes, docs, wikis, and tasks in a unified workspace, letting teams build custom workflows for collaboration and knowledge management.
- Google Sheets (slug: googlesheets) - Google Sheets is a cloud based spreadsheet tool enabling real time collaboration, data analysis, and integration with other Google Workspace apps.
- Slack (slug: slack) - Slack is a channel based messaging platform that helps teams collaborate, integrate software tools, and surface information within a secure environment.
- Outlook (slug: outlook) - Outlook is Microsoft's email and calendaring platform integrating contacts, tasks, and scheduling so users can manage communications and events together.
- Google Drive (slug: googledrive) - Google Drive is a cloud storage solution for uploading, sharing, and collaborating on files across devices, with robust search and offline access.
- Google Docs (slug: googledocs) - Google Docs is a cloud based word processor with real time collaboration, version history, and integration with other Google Workspace apps.
- Hubspot (slug: hubspot) - HubSpot is an inbound marketing, sales, and customer service platform integrating CRM, email automation, and analytics to nurture leads and manage customer experiences.
- Linear (slug: linear) - Linear is a streamlined issue tracking and project planning tool for modern teams, featuring fast workflows, keyboard shortcuts, and GitHub integrations.
- Jira (slug: jira) - Jira is a tool for bug tracking, issue tracking, and agile project management.
- Youtube (slug: youtube) - YouTube is a video sharing platform supporting user generated content, live streaming, and monetization for marketing, education, and entertainment.
- Slackbot (slug: slackbot) - Slackbot automates responses and reminders within Slack, assisting with tasks like onboarding, FAQs, and notifications to streamline team productivity.
- Canvas (slug: canvas) - Canvas is a learning management system supporting online courses, assignments, grading, and collaboration for schools and universities.
- Discord (slug: discord) - Discord is an instant messaging and VoIP social platform.
- Asana (slug: asana) - Asana helps teams organize, track, and manage their work.
- One drive (slug: one_drive) - OneDrive is Microsoft's cloud storage solution enabling users to store, sync, and share files with offline access and enterprise security.
- Salesforce (slug: salesforce) - Salesforce is a CRM platform integrating sales, service, marketing, and analytics to build customer relationships and drive growth.
- Trello (slug: trello) - Trello is a web based, kanban style, list making application for organizing tasks.
- Stripe (slug: stripe) - Stripe offers online payment infrastructure, fraud prevention, and APIs enabling businesses to accept and manage payments globally.
- Mailchimp (slug: mailchimp) - Mailchimp is an email marketing and automation platform providing campaign templates, audience segmentation, and performance analytics.
- Fireflies (slug: fireflies) - Fireflies.ai helps teams transcribe, summarize, search, and analyze voice conversations.
- Coda (slug: coda) - Coda is a collaborative workspace platform that turns documents into powerful tools for team productivity and project management.
- Pipedrive (slug: pipedrive) - Pipedrive is a sales management tool centered on pipeline visualization, lead tracking, activity reminders, and automation.
- Zendesk (slug: zendesk) - Zendesk provides customer support software with ticketing, live chat, and knowledge base features for efficient helpdesk operations.
- Google Super (slug: googlesuper) - Google Super App combines Google services including Drive, Calendar, Gmail, Sheets, Analytics, and Ads for unified management.
- Todoist (slug: todoist) - Todoist is a task management tool for creating to do lists, setting deadlines, and collaborating with reminders and cross platform syncing.
- Agent mail (slug: agent_mail) - AgentMail gives AI agents their own email inboxes so they can send, receive, and act upon emails for communication with services, people, and other agents.
- Google Slides (slug: googleslides) - Google Slides is a cloud based presentation editor with real time collaboration, templates, and Workspace integrations.
- Spotify (slug: spotify) - Spotify is a digital music and podcast streaming service with personalized playlists and social sharing features.
- Timelinesai (slug: timelinesai) - TimelinesAI enables teams to manage and automate WhatsApp communications, integrating with CRMs to streamline workflows.
You can create two types of local triggers:
### One-Time Triggers
- Execute once at a specific date and time
- Use config_type: "one_time_trigger"
- Require scheduledTime (ISO datetime string) in config_changes
- Require input.messages array defining what messages to send to agents
### Recurring Triggers
- Execute repeatedly based on a cron schedule
- Use config_type: "recurring_trigger"
- Require cron (cron expression) in config_changes
- Require input.messages array defining what messages to send to agents
### When to Create Triggers
- User asks for scheduled automation (daily reports, weekly summaries)
- User mentions specific times ("every morning at 9 AM", "next Friday at 2 PM")
- User wants periodic tasks (monitoring, maintenance, data syncing)
### Common Cron Patterns
- "0 9 * * *" - Daily at 9:00 AM
- "0 8 * * 1" - Every Monday at 8:00 AM
- "*/15 * * * *" - Every 15 minutes
- "0 0 1 * *" - First day of month at midnight
### Example Trigger Actions
CRITICAL: When creating triggers, follow the EXACT format shown below with comments above the JSON:
- Put "action", "config_type", and "name" as comments (starting with //) ABOVE the JSON
- The JSON should contain "change_description" and "config_changes"
- Always use "action: create_new" for new triggers
One-time trigger example (COPY THIS EXACT FORMAT):
// action: create_new
// config_type: one_time_trigger
// name: Weekly Report - Dec 15
{
"change_description": "Create a one-time trigger to generate weekly report on December 15th at 2 PM",
"config_changes": {
"scheduledTime": "2024-12-15T14:00:00Z",
"input": {
"messages": [{"role": "user", "content": "Generate the weekly performance report"}]
}
}
}
Recurring trigger example (COPY THIS EXACT FORMAT):
// action: create_new
// config_type: recurring_trigger
// name: Daily Status Check
{
"change_description": "Create a recurring trigger to check system status every morning at 9 AM",
"config_changes": {
"cron": "0 9 * * *",
"input": {
"messages": [{"role": "user", "content": "Check system status and alert if any issues found"}]
}
}
}
### Editing and Deleting Triggers
You can also edit or delete existing triggers that are shown in the current workflow context.
Edit trigger example:
// action: edit
// config_type: recurring_trigger
// name: Daily Status Check
{
"change_description": "Update the daily status check to run at 10 AM instead of 9 AM",
"config_changes": {
"cron": "0 10 * * *"
}
}
Delete trigger example:
// action: delete
// config_type: one_time_trigger
// name: Weekly Report - Dec 15
{
"change_description": "Remove the one-time trigger for weekly report as it's no longer needed"
}
### External Triggers
External triggers connect to services like Gmail, Slack, GitHub, Google Sheets, etc. When creating external triggers, provide minimal default configuration - the user will complete setup via the UI.
External trigger creation examples (COPY THIS EXACT FORMAT):
// action: create_new
// config_type: external_trigger
// name: New Gmail Message Received
{
"change_description": "Add the Gmail trigger for new message received with default configuration (checks INBOX every 1 minute for the authenticated user).",
"config_changes": {
"triggerTypeSlug": "GMAIL_NEW_GMAIL_MESSAGE",
"toolkitSlug": "gmail",
"triggerConfig": {
"interval": 1,
"labelIds": "INBOX",
"query": "",
"userId": "me"
}
}
}
// action: create_new
// config_type: external_trigger
// name: New Rows in Google Sheet
{
"change_description": "Add the Google Sheets trigger to detect new rows with default configuration",
"config_changes": {
"triggerTypeSlug": "GOOGLESHEETS_NEW_ROWS_IN_GOOGLE_SHEET",
"toolkitSlug": "googlesheets",
"triggerConfig": {
"interval": 1,
"sheet_name": "Sheet1",
"start_row": 2,
"spreadsheet_id": ""
}
}
}
External trigger deletion:
// action: delete
// config_type: external_trigger
// name: Slack Message Received
{
"change_description": "Remove the Slack message trigger as we're switching to a different notification system"
}
</about_triggers>
<about_pipelines> <about_pipelines>
## Section: Creating and Managing Pipelines ## Section: Creating and Managing Pipelines
@ -260,4 +453,4 @@ Below are details you should use when a user asks questions on how to use the pr
{USING_ROWBOAT_DOCS} {USING_ROWBOAT_DOCS}
</general_guidelines> </general_guidelines>
`; `;

View file

@ -0,0 +1,70 @@
const RANGE_SEPARATOR = "-";
const STEP_SEPARATOR = "/";
export function isValidCronExpression(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return false;
}
const [minute, hour, day, month, dayOfWeek] = parts;
const validatePart = (part: string, max: number): boolean => {
if (part === "*") {
return true;
}
if (part.includes(STEP_SEPARATOR)) {
const [range, step] = part.split(STEP_SEPARATOR);
if (!step) {
return false;
}
const stepValue = Number(step);
if (!Number.isInteger(stepValue) || stepValue <= 0) {
return false;
}
if (range === "*") {
return stepValue <= max;
}
return validatePart(range, max);
}
if (part.includes(RANGE_SEPARATOR)) {
const [start, end] = part.split(RANGE_SEPARATOR);
if (start === undefined || end === undefined) {
return false;
}
const startValue = Number(start);
const endValue = Number(end);
if (!Number.isInteger(startValue) || !Number.isInteger(endValue)) {
return false;
}
if (startValue > endValue) {
return false;
}
return startValue >= 0 && endValue <= max;
}
const value = Number(part);
if (!Number.isInteger(value)) {
return false;
}
return value >= 0 && value <= max;
};
return (
validatePart(minute, 59) &&
validatePart(hour, 23) &&
validatePart(day, 31) &&
validatePart(month, 12) &&
validatePart(dayOfWeek, 7)
);
}

View file

@ -17,6 +17,15 @@ export const ListedRecurringRuleItem = RecurringJobRule.omit({
input: true, input: true,
}); });
/**
* Schema for updating a recurring job rule.
*/
export const UpdateRecurringRuleSchema = RecurringJobRule
.pick({
input: true,
cron: true,
});
/** /**
* Repository interface for managing recurring job rules in the system. * Repository interface for managing recurring job rules in the system.
* *
@ -82,6 +91,16 @@ export interface IRecurringJobRulesRepository {
*/ */
toggle(id: string, disabled: boolean): Promise<z.infer<typeof RecurringJobRule>>; toggle(id: string, disabled: boolean): Promise<z.infer<typeof RecurringJobRule>>;
/**
* Updates a recurring job rule with new input and cron expression.
*
* @param id - The unique identifier of the recurring job rule to update
* @param data - The update data containing input messages and cron expression
* @returns Promise resolving to the updated recurring job rule
* @throws {NotFoundError} if the recurring job rule doesn't exist
*/
update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>>;
/** /**
* Deletes a recurring job rule by its unique identifier. * Deletes a recurring job rule by its unique identifier.
* *

View file

@ -24,6 +24,17 @@ export const UpdateJobSchema = ScheduledJobRule.pick({
output: true, output: true,
}); });
/**
* Schema for updating a scheduled job rule's next run configuration.
*/
export const UpdateScheduledRuleSchema = ScheduledJobRule
.pick({
input: true,
})
.extend({
scheduledTime: z.string().datetime(),
});
/** /**
* Repository interface for managing scheduled job rules in the system. * Repository interface for managing scheduled job rules in the system.
* *
@ -69,6 +80,16 @@ export interface IScheduledJobRulesRepository {
*/ */
update(id: string, data: z.infer<typeof UpdateJobSchema>): Promise<z.infer<typeof ScheduledJobRule>>; update(id: string, data: z.infer<typeof UpdateJobSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
/**
* Updates a scheduled job rule with new input and scheduled time.
*
* @param id - The unique identifier of the scheduled job rule to update
* @param data - The update data containing input messages and scheduled time
* @returns Promise resolving to the updated scheduled job rule
* @throws {NotFoundError} if the scheduled job rule doesn't exist
*/
updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
/** /**
* Releases a scheduled job rule after it has been executed. * Releases a scheduled job rule after it has been executed.
* *
@ -103,4 +124,4 @@ export interface IScheduledJobRulesRepository {
* @returns Promise resolving to void * @returns Promise resolving to void
*/ */
deleteByProjectId(projectId: string): Promise<void>; deleteByProjectId(projectId: string): Promise<void>;
} }

View file

@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
import { ICacheService } from '@/src/application/services/cache.service.interface'; import { ICacheService } from '@/src/application/services/cache.service.interface';
import { IUsageQuotaPolicy } from '@/src/application/policies/usage-quota.policy.interface'; import { IUsageQuotaPolicy } from '@/src/application/policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '@/src/application/policies/project-action-authorization.policy'; import { IProjectActionAuthorizationPolicy } from '@/src/application/policies/project-action-authorization.policy';
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from '@/src/entities/models/copilot'; import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from '@/src/entities/models/copilot';
import { Workflow } from '@/app/lib/types/workflow_types'; import { Workflow } from '@/app/lib/types/workflow_types';
import { USE_BILLING } from "@/app/lib/feature_flags"; import { USE_BILLING } from "@/app/lib/feature_flags";
import { authorize, getCustomerIdForProject } from "@/app/lib/billing"; import { authorize, getCustomerIdForProject } from "@/app/lib/billing";
@ -19,6 +19,7 @@ const inputSchema = z.object({
workflow: Workflow, workflow: Workflow,
context: CopilotChatContext.nullable(), context: CopilotChatContext.nullable(),
dataSources: z.array(DataSourceSchemaForCopilot).optional(), dataSources: z.array(DataSourceSchemaForCopilot).optional(),
triggers: z.array(TriggerSchemaForCopilot).optional(),
}), }),
}); });

View file

@ -90,6 +90,7 @@ export class RunCopilotCachedTurnUseCase implements IRunCopilotCachedTurnUseCase
cachedTurn.messages, cachedTurn.messages,
cachedTurn.workflow, cachedTurn.workflow,
cachedTurn.dataSources || [], cachedTurn.dataSources || [],
cachedTurn.triggers || [],
)) { )) {
yield event; yield event;
} }

View file

@ -5,6 +5,7 @@ import { IProjectActionAuthorizationPolicy } from '../../policies/project-action
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface'; import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule'; import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
import { Message } from '@/app/lib/types/types'; import { Message } from '@/app/lib/types/types';
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
const inputSchema = z.object({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
@ -42,7 +43,7 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> { async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
// Validate cron expression // Validate cron expression
if (!this.isValidCronExpression(request.cron)) { if (!isValidCronExpression(request.cron)) {
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek'); throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
} }
@ -66,31 +67,4 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
return rule; return rule;
} }
private isValidCronExpression(cron: string): boolean {
const parts = cron.split(' ');
if (parts.length !== 5) {
return false;
}
// Basic validation - in production you'd want more sophisticated validation
const [minute, hour, day, month, dayOfWeek] = parts;
// Check if parts are valid
const isValidPart = (part: string) => {
if (part === '*') return true;
if (part.includes('/')) {
const [range, step] = part.split('/');
if (range === '*' || (parseInt(step) > 0 && parseInt(step) <= 59)) return true;
return false;
}
if (part.includes('-')) {
const [start, end] = part.split('-');
return !isNaN(parseInt(start)) && !isNaN(parseInt(end)) && parseInt(start) <= parseInt(end);
}
return !isNaN(parseInt(part));
};
return isValidPart(minute) && isValidPart(hour) && isValidPart(day) && isValidPart(month) && isValidPart(dayOfWeek);
}
} }

View file

@ -0,0 +1,69 @@
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
import { Message } from '@/app/lib/types/types';
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
cron: z.string(),
});
export interface IUpdateRecurringJobRuleUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
}
export class UpdateRecurringJobRuleUseCase implements IUpdateRecurringJobRuleUseCase {
private readonly recurringJobRulesRepository: IRecurringJobRulesRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
recurringJobRulesRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
recurringJobRulesRepository: IRecurringJobRulesRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.recurringJobRulesRepository = recurringJobRulesRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
if (!isValidCronExpression(request.cron)) {
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
}
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId: request.projectId,
});
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
const rule = await this.recurringJobRulesRepository.fetch(request.ruleId);
if (!rule || rule.projectId !== request.projectId) {
throw new NotFoundError('Recurring job rule not found');
}
return await this.recurringJobRulesRepository.update(request.ruleId, {
input: request.input,
cron: request.cron,
});
}
}

View file

@ -0,0 +1,64 @@
import { NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IScheduledJobRulesRepository } from '../../repositories/scheduled-job-rules.repository.interface';
import { ScheduledJobRule } from '@/src/entities/models/scheduled-job-rule';
import { Message } from '@/app/lib/types/types';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
scheduledTime: z.string().datetime(),
});
export interface IUpdateScheduledJobRuleUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
}
export class UpdateScheduledJobRuleUseCase implements IUpdateScheduledJobRuleUseCase {
private readonly scheduledJobRulesRepository: IScheduledJobRulesRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
scheduledJobRulesRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
scheduledJobRulesRepository: IScheduledJobRulesRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.scheduledJobRulesRepository = scheduledJobRulesRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId: request.projectId,
});
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId);
if (!rule || rule.projectId !== request.projectId) {
throw new NotFoundError('Scheduled job rule not found');
}
return await this.scheduledJobRulesRepository.updateRule(request.ruleId, {
input: request.input,
scheduledTime: request.scheduledTime,
});
}
}

View file

@ -2,6 +2,9 @@ import { z } from "zod";
import { Workflow } from "@/app/lib/types/workflow_types"; import { Workflow } from "@/app/lib/types/workflow_types";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
import { DataSource } from "@/src/entities/models/data-source"; import { DataSource } from "@/src/entities/models/data-source";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
export const DataSourceSchemaForCopilot = DataSource.pick({ export const DataSourceSchemaForCopilot = DataSource.pick({
id: true, id: true,
@ -10,6 +13,43 @@ export const DataSourceSchemaForCopilot = DataSource.pick({
data: true, data: true,
}); });
export const ScheduledJobRuleSchemaForCopilot = ScheduledJobRule.pick({
id: true,
nextRunAt: true,
status: true,
input: true,
}).extend({
type: z.literal('one_time'),
name: z.string(),
});
export const RecurringJobRuleSchemaForCopilot = RecurringJobRule.pick({
id: true,
cron: true,
nextRunAt: true,
disabled: true,
input: true,
}).extend({
type: z.literal('recurring'),
name: z.string(),
});
export const ComposioTriggerDeploymentSchemaForCopilot = ComposioTriggerDeployment.pick({
id: true,
triggerTypeName: true,
toolkitSlug: true,
triggerTypeSlug: true,
triggerConfig: true,
}).extend({
type: z.literal('external'),
});
export const TriggerSchemaForCopilot = z.union([
ScheduledJobRuleSchemaForCopilot,
RecurringJobRuleSchemaForCopilot,
ComposioTriggerDeploymentSchemaForCopilot,
]);
export const CopilotUserMessage = z.object({ export const CopilotUserMessage = z.object({
role: z.literal('user'), role: z.literal('user'),
content: z.string(), content: z.string(),
@ -21,7 +61,7 @@ export const CopilotAssistantMessageTextPart = z.object({
export const CopilotAssistantMessageActionPart = z.object({ export const CopilotAssistantMessageActionPart = z.object({
type: z.literal("action"), type: z.literal("action"),
content: z.object({ content: z.object({
config_type: z.enum(['tool', 'agent', 'prompt', 'pipeline', 'start_agent']), config_type: z.enum(['tool', 'agent', 'prompt', 'pipeline', 'start_agent', 'one_time_trigger', 'recurring_trigger', 'external_trigger']),
action: z.enum(['create_new', 'edit', 'delete']), action: z.enum(['create_new', 'edit', 'delete']),
name: z.string(), name: z.string(),
change_description: z.string(), change_description: z.string(),
@ -60,6 +100,7 @@ export const CopilotAPIRequest = z.object({
workflow: Workflow, workflow: Workflow,
context: CopilotChatContext.nullable(), context: CopilotChatContext.nullable(),
dataSources: z.array(DataSourceSchemaForCopilot).optional(), dataSources: z.array(DataSourceSchemaForCopilot).optional(),
triggers: z.array(TriggerSchemaForCopilot).optional(),
}); });
export const CopilotAPIResponse = z.union([ export const CopilotAPIResponse = z.union([
z.object({ z.object({

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { Filter, ObjectId } from "mongodb"; import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb"; import { db } from "@/app/lib/mongodb";
import { CreateRecurringRuleSchema, IRecurringJobRulesRepository, ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface"; import { CreateRecurringRuleSchema, IRecurringJobRulesRepository, ListedRecurringRuleItem, UpdateRecurringRuleSchema } from "@/src/application/repositories/recurring-job-rules.repository.interface";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule"; import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { NotFoundError } from "@/src/entities/errors/common"; import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list"; import { PaginatedList } from "@/src/entities/common/paginated-list";
@ -208,6 +208,31 @@ export class MongoDBRecurringJobRulesRepository implements IRecurringJobRulesRep
return await this.updateNextRunAt(id, result.cron); return await this.updateNextRunAt(id, result.cron);
} }
/**
* Updates a recurring job rule with new input and schedule.
*/
async update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>> {
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
input: data.input,
cron: data.cron,
updatedAt: now,
},
},
{ returnDocument: "after" },
);
if (!result) {
throw new NotFoundError(`Recurring job rule ${id} not found`);
}
return await this.updateNextRunAt(id, data.cron);
}
/** /**
* Deletes a recurring job rule by its unique identifier. * Deletes a recurring job rule by its unique identifier.
*/ */

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { Filter, ObjectId } from "mongodb"; import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb"; import { db } from "@/app/lib/mongodb";
import { CreateRuleSchema, IScheduledJobRulesRepository, ListedRuleItem, UpdateJobSchema } from "@/src/application/repositories/scheduled-job-rules.repository.interface"; import { CreateRuleSchema, IScheduledJobRulesRepository, ListedRuleItem, UpdateJobSchema, UpdateScheduledRuleSchema } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule"; import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { NotFoundError } from "@/src/entities/errors/common"; import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list"; import { PaginatedList } from "@/src/entities/common/paginated-list";
@ -138,6 +138,41 @@ export class MongoDBScheduledJobRulesRepository implements IScheduledJobRulesRep
return this.convertDocToModel(result); return this.convertDocToModel(result);
} }
/**
* Reconfigures a scheduled job rule's input and next run time.
*/
async updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
const scheduledDate = new Date(data.scheduledTime);
const nextRunAtSeconds = Math.floor(scheduledDate.getTime() / 1000);
const nextRunAt = Math.floor(nextRunAtSeconds / 60) * 60;
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
input: data.input,
nextRunAt,
status: "pending",
workerId: null,
lastWorkerId: null,
updatedAt: now,
},
$unset: {
output: "",
processedAt: "",
},
},
{ returnDocument: "after" },
);
if (!result) {
throw new NotFoundError(`Scheduled job rule ${id} not found`);
}
return this.convertDocToModel(result);
}
/** /**
* Updates a scheduled job rule with new status and output data. * Updates a scheduled job rule with new status and output data.
*/ */

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from '@/src/entities/models/copilot'; import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from '@/src/entities/models/copilot';
import { Workflow } from '@/app/lib/types/workflow_types'; import { Workflow } from '@/app/lib/types/workflow_types';
import { ICreateCopilotCachedTurnUseCase } from "@/src/application/use-cases/copilot/create-copilot-cached-turn.use-case"; import { ICreateCopilotCachedTurnUseCase } from "@/src/application/use-cases/copilot/create-copilot-cached-turn.use-case";
import { BadRequestError } from "@/src/entities/errors/common"; import { BadRequestError } from "@/src/entities/errors/common";
@ -14,6 +14,7 @@ const inputSchema = z.object({
workflow: Workflow, workflow: Workflow,
context: CopilotChatContext.nullable(), context: CopilotChatContext.nullable(),
dataSources: z.array(DataSourceSchemaForCopilot).optional(), dataSources: z.array(DataSourceSchemaForCopilot).optional(),
triggers: z.array(TriggerSchemaForCopilot).optional(),
}), }),
}); });

View file

@ -0,0 +1,50 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IUpdateRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(z.any()),
}),
cron: z.string(),
});
export interface IUpdateRecurringJobRuleController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
}
export class UpdateRecurringJobRuleController implements IUpdateRecurringJobRuleController {
private readonly updateRecurringJobRuleUseCase: IUpdateRecurringJobRuleUseCase;
constructor({
updateRecurringJobRuleUseCase,
}: {
updateRecurringJobRuleUseCase: IUpdateRecurringJobRuleUseCase,
}) {
this.updateRecurringJobRuleUseCase = updateRecurringJobRuleUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, ruleId, input, cron } = result.data;
return await this.updateRecurringJobRuleUseCase.execute({
caller,
userId,
apiKey,
projectId,
ruleId,
input,
cron,
});
}
}

View file

@ -0,0 +1,51 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IUpdateScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { Message } from "@/app/lib/types/types";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
scheduledTime: z.string().datetime(),
});
export interface IUpdateScheduledJobRuleController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
}
export class UpdateScheduledJobRuleController implements IUpdateScheduledJobRuleController {
private readonly updateScheduledJobRuleUseCase: IUpdateScheduledJobRuleUseCase;
constructor({
updateScheduledJobRuleUseCase,
}: {
updateScheduledJobRuleUseCase: IUpdateScheduledJobRuleUseCase,
}) {
this.updateScheduledJobRuleUseCase = updateScheduledJobRuleUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, ruleId, input, scheduledTime } = result.data;
return await this.updateScheduledJobRuleUseCase.execute({
caller,
userId,
apiKey,
projectId,
ruleId,
input,
scheduledTime,
});
}
}