diff --git a/examples/linear-coding-agent/.env.example b/examples/linear-coding-agent/.env.example deleted file mode 100644 index 7479f55d2..000000000 --- a/examples/linear-coding-agent/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# GitHub Configuration -GITHUB_TOKEN=your_github_token -REPO_OWNER=your_github_username_or_organization -REPO_NAME=your_repository_name -BASE_BRANCH=main - -# Linear Configuration -LINEAR_API_KEY=your_linear_api_key -LINEAR_WEBHOOK_SECRET=your_linear_webhook_signing_secret - -# OpenAI Configuration -OPENAI_API_KEY=your_openai_api_key - -# Server Configuration -PORT=3000 -ACTOR_SERVER_URL=http://localhost:8787 \ No newline at end of file diff --git a/examples/linear-coding-agent/.gitignore b/examples/linear-coding-agent/.gitignore deleted file mode 100644 index 79b7a1192..000000000 --- a/examples/linear-coding-agent/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.actorcore -node_modules \ No newline at end of file diff --git a/examples/linear-coding-agent/README.md b/examples/linear-coding-agent/README.md deleted file mode 100644 index 3b1b3860d..000000000 --- a/examples/linear-coding-agent/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Linear Coding Agent for RivetKit - -Example project demonstrating AI-powered coding agent with Linear and GitHub integration with [RivetKit](https://rivetkit.org). - -[Learn More →](https://github.com/rivet-gg/rivetkit) - -[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) - -## Getting Started - -### Prerequisites - -- Node.js -- GitHub repository access -- Linear workspace -- Anthropic API key (for Claude) - -### Installation - -```sh -git clone https://github.com/rivet-gg/rivetkit -cd rivetkit/examples/linear-coding-agent -npm install -``` - -### Development - -```sh -npm run dev -``` - -Configure your environment variables for GitHub, Linear, and Anthropic API keys. The agent will automatically handle Linear webhooks and create GitHub PRs based on Linear issues. - -## License - -Apache 2.0 \ No newline at end of file diff --git a/examples/linear-coding-agent/package.json b/examples/linear-coding-agent/package.json deleted file mode 100644 index dd79c3fe6..000000000 --- a/examples/linear-coding-agent/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "linear-coding-agent", - "version": "0.9.0-rc.1", - "private": true, - "type": "module", - "scripts": { - "dev": "concurrently --raw \"pnpm dev:actors\" \"pnpm dev:server\" \"pnpm dev:ngrok\"", - "dev:actors": "npx rivetkit/cli@latest dev src/workers/registry.ts", - "dev:server": "tsx --watch src/server/index.ts", - "dev:ngrok": "ngrok http 3000", - "check-types": "tsc --noEmit" - }, - "devDependencies": { - "@types/dotenv": "^8.2.3", - "@types/express": "^5", - "@types/node": "^22.13.9", - "@types/prompts": "^2", - "concurrently": "^9.1.2", - "prompts": "^2.4.2", - "rivetkit": "workspace:*", - "tsx": "^3.12.7", - "typescript": "^5.5.2", - "vitest": "^3.1.1" - }, - "dependencies": { - "@ai-sdk/anthropic": "^1.2.10", - "@hono/node-server": "^1.14.1", - "@linear/sdk": "^7.0.0", - "@octokit/rest": "^19.0.13", - "@rivetkit/nodejs": "workspace:*", - "ai": "^4.3.9", - "body-parser": "^2.2.0", - "dotenv": "^16.5.0", - "express": "^5.1.0", - "hono": "^4.7.7", - "linear-webhook": "^0.1.3", - "zod": "^3.24.3" - }, - "example": { - "platforms": [ - "*" - ] - }, - "stableVersion": "0.8.0" -} diff --git a/examples/linear-coding-agent/src/config.ts b/examples/linear-coding-agent/src/config.ts deleted file mode 100644 index bdd72ca72..000000000 --- a/examples/linear-coding-agent/src/config.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Application configuration loaded from environment variables - */ - -export interface Config { - githubToken: string; - linearApiKey: string; - repoOwner: string; - repoName: string; - baseBranch: string; -} - -/** - * Load and validate configuration from environment variables - */ -export function getConfig(): Config { - // Required environment variables - const requiredEnvVars = [ - 'GITHUB_TOKEN', - 'LINEAR_API_KEY', - 'ANTHROPIC_API_KEY', - 'REPO_OWNER', - 'REPO_NAME' - ]; - - // Check for missing environment variables - const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]); - if (missingEnvVars.length > 0) { - throw new Error(`Missing required environment variables: ${missingEnvVars.join(', ')}`); - } - - return { - githubToken: process.env.GITHUB_TOKEN!, - linearApiKey: process.env.LINEAR_API_KEY!, - repoOwner: process.env.REPO_OWNER!, - repoName: process.env.REPO_NAME!, - baseBranch: process.env.BASE_BRANCH || 'main', - }; -} diff --git a/examples/linear-coding-agent/src/server.ts b/examples/linear-coding-agent/src/server.ts deleted file mode 100644 index 4bf6ba53b..000000000 --- a/examples/linear-coding-agent/src/server.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { serve } from "@rivetkit/nodejs"; -import { registry } from "./workers/registry"; - -serve(registry); diff --git a/examples/linear-coding-agent/src/server/index.ts b/examples/linear-coding-agent/src/server/index.ts deleted file mode 100644 index 21e610451..000000000 --- a/examples/linear-coding-agent/src/server/index.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Hono } from "hono"; -import { serve } from "@hono/node-server"; -import dotenv from "dotenv"; -import { createClient } from "rivetkit/client"; -import { registry } from "../workers/registry"; -import type { Registry } from "../workers/registry"; -import type { LinearWebhookEvent } from "../types"; - -// Load environment variables -dotenv.config(); - -// Create Hono app -const server = new Hono(); -const PORT = process.env.PORT || 8080; - -// Create actor client -const ACTOR_SERVER_URL = - process.env.ACTOR_SERVER_URL || "http://localhost:6420"; -const client = createClient(ACTOR_SERVER_URL); - -// Middleware to initialize agent -server.use("*", async (c, next) => { - try { - // Initialize any new actor instances with repository settings - await next(); - } catch (error) { - console.error("[SERVER] Error in middleware:", error); - return c.json( - { - status: "error", - statusEmoji: "❌", - message: error instanceof Error ? error.message : "Unknown error", - }, - 500, - ); - } -}); - -// Route for Linear webhooks -server.post("/api/webhook/linear", async (c) => { - try { - // Get raw payload for signature verification - const rawBody = await c.req.text(); - - // Verify webhook signature - const signature = c.req.header("linear-signature"); - const webhookSecret = process.env.LINEAR_WEBHOOK_SECRET; - - if (webhookSecret) { - // Only verify if webhook secret is configured - const crypto = await import("crypto"); - const computedSignature = crypto - .createHmac("sha256", webhookSecret) - .update(rawBody) - .digest("hex"); - - if (signature !== computedSignature) { - console.error("[SERVER] Invalid webhook signature"); - return c.json( - { - status: "error", - statusEmoji: "❌", - message: "Invalid webhook signature", - }, - 401, - ); - } - } else { - console.warn( - "[SERVER] LINEAR_WEBHOOK_SECRET not configured, skipping signature verification", - ); - } - - // Parse the webhook payload - const event = JSON.parse(rawBody) as LinearWebhookEvent; - - console.log( - `[SERVER] Received Linear webhook: ${event.type} - ${event.action}`, - ); - - // Determine the issue ID to use as a tag for the actor - const issueId = event.data.issue?.id ?? event.data.id; - if (!issueId) { - console.error("[SERVER] No issue ID found in webhook event"); - return c.json( - { - status: "error", - statusEmoji: "❌", - message: "No issue ID found in webhook event", - }, - 400, - ); - } - - // Create or get a coding agent instance with the issue ID as a key - // This ensures each issue gets its own actor instance - console.log(`[SERVER] Getting actor for issue: ${issueId}`); - const actorClient = client.codingAgent.getOrCreate(issueId).connect(); - - // Initialize the agent if needed - console.log(`[SERVER] Initializing actor for issue: ${issueId}`); - await actorClient.initialize(); - - // Determine which handler to use based on the event type and action - if (event.type === "Issue" && event.action === "create") { - // Handle new issue creation - console.log( - `[SERVER] Processing issue creation: ${issueId} - ${event.data.title}`, - ); - const result = await actorClient.issueCreated(event); - return c.json({ - status: "success", - message: result.message || "Issue creation event queued for processing", - requestId: result.requestId, - }); - } else if (event.type === "Comment" && event.action === "create") { - // Handle new comment with enhanced logging - console.log(`[SERVER] Processing comment creation on issue: ${issueId}`); - console.log( - `[SERVER] Comment details: ID=${event.data.id}, Body="${event.data.body?.substring(0, 100)}${event.data.body && event.data.body.length > 100 ? "..." : ""}", UserIsBot=${event.data.user?.isBot}`, - ); - - // Early detection of bot comments to avoid unnecessary processing - if (event.data.user?.isBot) { - console.log( - `[SERVER] Skipping comment from bot user - preventing feedback loop`, - ); - return c.json({ - status: "skipped", - message: "Comment skipped - from bot user", - statusEmoji: "⏭", - }); - } - - // Check for bot emojis at the start of comment - if ( - event.data.body && - (event.data.body.startsWith("✅") || - event.data.body.startsWith("❌") || - event.data.body.startsWith("🤖")) - ) { - console.log( - `[SERVER] Skipping comment with bot emoji: "${event.data.body?.substring(0, 20)}..."`, - ); - return c.json({ - status: "skipped", - message: "Comment skipped - contains bot emoji", - statusEmoji: "⏭", - }); - } - - const result = await actorClient.commentCreated(event); - console.log( - `[SERVER] Comment sent to actor for processing, requestId: ${result.requestId}`, - ); - - return c.json({ - status: "success", - message: - result.message || "Comment creation event queued for processing", - requestId: result.requestId, - }); - } else if (event.type === "Issue" && event.action === "update") { - // Handle issue updates (status changes) - console.log( - `[SERVER] Processing issue update: ${issueId} - New state: ${event.data.state?.name}`, - ); - const result = await actorClient.issueUpdated(event); - return c.json({ - status: "success", - message: result.message || "Issue update event queued for processing", - requestId: result.requestId, - }); - } else { - // Unhandled event type - console.log( - `[SERVER] Unhandled event type: ${event.type} - ${event.action}`, - ); - return c.json({ - status: "skipped", - statusEmoji: "⏭", - message: "Event type not handled", - }); - } - } catch (error) { - console.error("[SERVER] Error processing webhook:", error); - return c.json( - { - status: "error", - statusEmoji: "❌", - message: error instanceof Error ? error.message : "Unknown error", - }, - 500, - ); - } -}); - -// Health check endpoint -server.get("/health", (c) => { - console.log("[SERVER] Health check requested"); - return c.json({ - status: "ok", - statusEmoji: "✅", - message: "Service is healthy", - }); -}); - -// Start the server -console.log(`[SERVER] Starting server on port ${PORT}...`); -serve( - { - fetch: server.fetch, - port: Number(PORT), - }, - (info) => { - console.log(`[SERVER] Running on port ${info.port}`); - console.log( - `[SERVER] Linear webhook URL: http://localhost:${info.port}/api/webhook/linear`, - ); - }, -); diff --git a/examples/linear-coding-agent/src/types.ts b/examples/linear-coding-agent/src/types.ts deleted file mode 100644 index 63dccdee4..000000000 --- a/examples/linear-coding-agent/src/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Shared types for the application - */ - -// Linear webhook event for server integration -export interface LinearWebhookEvent { - type: string; - action: string; - data: { - id: string; - identifier: string; - title?: string; - description?: string; - state?: { - name: string; - id?: string; - }; - body?: string; - user?: { - isBot?: boolean; - }; - issue?: { - id: string; - }; - }; - updatedFrom?: { - stateId?: string; - }; - updatedAt?: string; // ISO timestamp when the event was updated -} diff --git a/examples/linear-coding-agent/src/workers/coding-agent/github.ts b/examples/linear-coding-agent/src/workers/coding-agent/github.ts deleted file mode 100644 index 65515d48f..000000000 --- a/examples/linear-coding-agent/src/workers/coding-agent/github.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { Octokit } from "@octokit/rest"; -import type { GitHubFile, GitHubFileContent, PullRequestInfo } from "./types"; -import type { Ctx } from "./mod"; -import { getConfig } from "../../config"; - - -/** - * Push changes to GitHub and refresh file tree - */ -export async function pushChangesToGitHub(c: Ctx, commitMessage: string) { - // Skip if no files were modified - if (Object.keys(c.state.code.modifiedFiles).length === 0) { - console.log('[GITHUB] No files modified, skipping push to GitHub'); - return true; - } - - console.log(`[GITHUB] Pushing changes to GitHub: ${Object.keys(c.state.code.modifiedFiles).length} files modified`); - console.log(`[GITHUB] Branch: ${c.state.github.branchName}, Commit message: ${commitMessage}`); - - // Push changes to GitHub - const result = await commitChanges( - c, - c.state.code.modifiedFiles, - c.state.github.branchName, - commitMessage - ); - - if (result) { - console.log(`[GITHUB] Successfully pushed changes to branch: ${c.state.github.branchName}`); - - // Successfully pushed changes, clear modified files - c.state.code.modifiedFiles = {}; - - // Refresh file tree - c.state.code.fileTree = await getFileTree(c, c.state.github.branchName); - } else { - console.error(`[GITHUB] Failed to push changes to branch: ${c.state.github.branchName}`); - } - - return result; -} - -/** - * Create a new branch from the specified base branch - */ -export async function createBranch(c: Ctx, branchName: string, baseBranch: string): Promise { - try { - console.log(`[GITHUB] Creating new branch: ${branchName} from ${baseBranch}`); - - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - // Get the SHA of the base branch - const { data: refData } = await octokit.git.getRef({ - owner: c.state.github.owner, - repo: c.state.github.repo, - ref: `heads/${baseBranch}`, - }); - - console.log(`[GITHUB] Retrieved base branch reference: ${baseBranch} (${refData.object.sha})`); - - // Create a new branch - await octokit.git.createRef({ - owner: c.state.github.owner, - repo: c.state.github.repo, - ref: `refs/heads/${branchName}`, - sha: refData.object.sha, - }); - - console.log(`[GITHUB] Branch created successfully: ${branchName}`); - return true; - } catch (error) { - console.error(`[GITHUB] Failed to create branch ${branchName}:`, error); - return false; - } -} - -/** - * Get the file tree for the specified branch - */ -export async function getFileTree(c: Ctx, branch: string): Promise { - try { - console.log(`[GITHUB] Fetching file tree for ${c.state.github.repo} (${branch})`); - - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - // Get the latest commit on the branch - const { data: refData } = await octokit.git.getRef({ - owner: c.state.github.owner, - repo: c.state.github.repo, - ref: `heads/${branch}`, - }); - - // Get the tree of that commit - const { data: treeData } = await octokit.git.getTree({ - owner: c.state.github.owner, - repo: c.state.github.repo, - tree_sha: refData.object.sha, - recursive: "1", - }); - - // Map to our GitHubFile type - const files = treeData.tree.map((item) => ({ - path: item.path || "", - type: item.type === "blob" ? "file" : "directory" as "file" | "directory", - sha: item.sha, - })); - - console.log(`[GITHUB] File tree fetched successfully: ${files.length} files`); - return files; - } catch (error) { - console.error(`[GITHUB] Failed to get file tree for branch ${branch}:`, error); - return []; - } -} - -/** - * Read the contents of a file from the specified branch - */ -export async function readFile(c: Ctx, path: string, branch: string): Promise { - try { - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - const { data } = await octokit.repos.getContent({ - owner: c.state.github.owner, - repo: c.state.github.repo, - path, - ref: branch, - }); - - // Handle case when data is an array (directory) instead of a file - if (Array.isArray(data)) { - throw new Error(`Path ${path} is a directory, not a file`); - } - - // Handle case where content might be undefined - if (!("content" in data)) { - throw new Error(`No content found for ${path}`); - } - - // Decode base64 content - const content = Buffer.from(data.content, "base64").toString(); - - return { - content, - sha: data.sha, - path, - }; - } catch (error) { - console.error(`[GITHUB] Failed to read file ${path}:`, error); - return null; - } -} - -/** - * Read multiple files at once - */ -export async function readFiles(c: Ctx, paths: string[], branch: string): Promise> { - const results: Record = {}; - await Promise.all( - paths.map(async (path) => { - results[path] = await readFile(c, path, branch); - }), - ); - return results; -} - -/** - * Commit changes to files - */ -export async function commitChanges( - c: Ctx, - files: Record, - branch: string, - message: string, -): Promise { - try { - console.log(`[GITHUB] Committing changes to ${branch}: ${Object.keys(files).length} files modified`); - - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - // First get the current commit to use as parent - const { data: refData } = await octokit.git.getRef({ - owner: c.state.github.owner, - repo: c.state.github.repo, - ref: `heads/${branch}`, - }); - const commitSha = refData.object.sha; - - // Get the current tree - const { data: commitData } = await octokit.git.getCommit({ - owner: c.state.github.owner, - repo: c.state.github.repo, - commit_sha: commitSha, - }); - const treeSha = commitData.tree.sha; - - // Create blobs for each file - const blobPromises = Object.entries(files).map( - async ([path, content]) => { - const { data } = await octokit.git.createBlob({ - owner: c.state.github.owner, - repo: c.state.github.repo, - content, - encoding: "utf-8", - }); - return { - path, - mode: "100644" as const, // Regular file - type: "blob" as const, - sha: data.sha, - }; - }, - ); - - const blobs = await Promise.all(blobPromises); - - // Create a new tree - const { data: newTree } = await octokit.git.createTree({ - owner: c.state.github.owner, - repo: c.state.github.repo, - base_tree: treeSha, - tree: blobs, - }); - - // Create a new commit - const { data: newCommit } = await octokit.git.createCommit({ - owner: c.state.github.owner, - repo: c.state.github.repo, - message, - tree: newTree.sha, - parents: [commitSha], - }); - - // Update the reference - await octokit.git.updateRef({ - owner: c.state.github.owner, - repo: c.state.github.repo, - ref: `heads/${branch}`, - sha: newCommit.sha, - }); - - console.log(`[GITHUB] Changes committed successfully to ${branch}`); - return true; - } catch (error) { - console.error(`[GITHUB] Failed to commit changes to ${branch}:`, error); - return false; - } -} - -/** - * Create a pull request - */ -export async function createPullRequest( - c: Ctx, - title: string, - body: string, - head: string, - base: string, -): Promise { - try { - console.log(`[GITHUB] Creating pull request: ${head} → ${base}`); - console.log(`[GITHUB] PR title: ${title}`); - - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - // First check if there are any commits between branches - try { - const { data: comparison } = await octokit.repos.compareCommits({ - owner: c.state.github.owner, - repo: c.state.github.repo, - base, - head, - }); - - if (comparison.total_commits === 0) { - console.error(`[GITHUB] Cannot create PR: No commits between ${base} and ${head}`); - return { - id: 0, - number: 0, - url: `https://github.com/${c.state.github.owner}/${c.state.github.repo}/tree/${head}`, - noDiff: true - }; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn(`[GITHUB] Could not compare branches: ${errorMessage}`); - // Continue anyway and let the PR creation attempt fail if needed - } - - const { data } = await octokit.pulls.create({ - owner: c.state.github.owner, - repo: c.state.github.repo, - title, - body, - head, - base, - }); - - console.log(`[GITHUB] Pull request created successfully: #${data.number} (${data.html_url})`); - - return { - id: data.id, - number: data.number, - url: data.html_url, - }; - } catch (error) { - if (error instanceof Error && error.message && error.message.includes("No commits between")) { - console.error(`[GITHUB] Failed to create PR: No commits between branches`); - return { - id: 0, - number: 0, - url: `https://github.com/${c.state.github.owner}/${c.state.github.repo}/tree/${head}`, - noDiff: true - }; - } - - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`[GITHUB] Failed to create pull request:`, errorMessage); - return null; - } -} - -/** - * Merge a pull request - */ -export async function mergePullRequest(c: Ctx, prNumber: number): Promise { - try { - console.log(`[GITHUB] Merging pull request #${prNumber}`); - - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - await octokit.pulls.merge({ - owner: c.state.github.owner, - repo: c.state.github.repo, - pull_number: prNumber, - }); - - console.log(`[GITHUB] Pull request #${prNumber} merged successfully`); - return true; - } catch (error) { - console.error(`[GITHUB] Failed to merge PR #${prNumber}:`, error); - return false; - } -} - -/** - * Close a pull request - */ -export async function closePullRequest(c: Ctx, prNumber: number): Promise { - try { - console.log(`[GITHUB] Closing pull request #${prNumber}`); - - const config = getConfig(); - const octokit = new Octokit({ auth: config.githubToken }); - - await octokit.pulls.update({ - owner: c.state.github.owner, - repo: c.state.github.repo, - pull_number: prNumber, - state: "closed", - }); - - console.log(`[GITHUB] Pull request #${prNumber} closed successfully`); - return true; - } catch (error) { - console.error(`[GITHUB] Failed to close PR #${prNumber}:`, error); - return false; - } -} \ No newline at end of file diff --git a/examples/linear-coding-agent/src/workers/coding-agent/linear-utils.ts b/examples/linear-coding-agent/src/workers/coding-agent/linear-utils.ts deleted file mode 100644 index 547d29607..000000000 --- a/examples/linear-coding-agent/src/workers/coding-agent/linear-utils.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Utility functions for Linear integration - */ -import { LinearClient } from "@linear/sdk"; -import { getConfig } from "../../config"; -import { Ctx } from "./mod"; -import { LLMMessage } from "./llm"; - -/** - * Add a comment to a Linear issue with appropriate bot indicators - * - * @param c Actor context - * @param issueId Linear issue ID - * @param comment Comment text to add - * @param status Optional status indicator (success, failure, or info) - * @returns Promise resolving to true if comment was added successfully, false otherwise - */ -export async function addBotComment( - c: Ctx, - issueId: string, - comment: string, - status: "success" | "failure" | "info" = "info", -): Promise<{ success: boolean; commentId?: string }> { - try { - const config = getConfig(); - const client = new LinearClient({ apiKey: config.linearApiKey }); - - // Get the issue - const issue = await client.issue(issueId); - - // Add emoji based on status - let statusEmoji = "🤖"; // Default bot indicator - if (status === "success") { - statusEmoji = "✅"; - } else if (status === "failure") { - statusEmoji = "❌"; - } - - // Create formatted comment with emoji and unique bot identifier - const formattedComment = `${statusEmoji} **Bot Update**: ${comment}`; - - console.log(`📝 [LINEAR] Adding bot comment to issue ${issueId}: ${formattedComment.substring(0, 100)}${formattedComment.length > 100 ? '...' : ''}`); - - // Create a comment via the Linear API - const createdComment = await client.createComment({ - issueId: issue.id, - body: formattedComment, - }); - const commentData = await createdComment.comment; - const commentId = commentData?.id; - - if (!commentId) { - console.warn( - `No comment ID returned from Linear API: ${JSON.stringify(createdComment)}`, - ); - } else { - console.log(`📝 [LINEAR] Successfully added comment with ID: ${commentId}`); - } - - return { success: true, commentId }; - } catch (error) { - console.error(`Failed to add comment to issue ${issueId}:`, error); - return { success: false }; - } -} - -/** - * Update an existing comment with new content - * - * @param commentId ID of the comment to update - * @param comment New comment text to set - * @param status Optional status indicator (success, failure, or info) - * @returns Promise resolving to true if comment was updated successfully, false otherwise - */ -export async function updateBotComment( - commentId: string, - comment: string, - status: "success" | "failure" | "info" = "info", -): Promise { - try { - const config = getConfig(); - const client = new LinearClient({ apiKey: config.linearApiKey }); - - // Add emoji based on status - let statusEmoji = "🤖"; // Default bot indicator - if (status === "success") { - statusEmoji = "✅"; - } else if (status === "failure") { - statusEmoji = "❌"; - } - - // Create formatted comment with emoji - const formattedComment = `${statusEmoji} **Bot Update**: ${comment}`; - - console.log(`📝 [LINEAR] Updating bot comment ${commentId}: ${formattedComment.substring(0, 100)}${formattedComment.length > 100 ? '...' : ''}`); - - // Use the Linear SDK to update the comment - const result = await client.updateComment(commentId, { - body: formattedComment, - }); - - console.log(`📝 [LINEAR] Comment update result:`, result); - - return result.success || false; - } catch (error) { - console.error(`Failed to update comment ${commentId}:`, error); - console.error(`Error details:`, error); - return false; - } -} - -const TRUNCATE_LENGTH = 200; - -/** - * Format LLM messages into a readable comment - * - * @param messages LLM conversation messages - * @returns Formatted comment text - */ -export function formatLLMMessagesForComment(messages: LLMMessage[]): string { - const parts: string[] = []; - - for (const message of messages) { - if (!message || !message.role) continue; - - if (message.role === "system") { - // Skip system messages for brevity - continue; - } - - if (message.role === "user") { - // Handle user messages - content might be string or complex object - let contentText = "Unknown content"; - if (typeof message.content === "string") { - contentText = message.content; - } else if (message.content && typeof message.content === "object") { - try { - contentText = JSON.stringify(message.content).substring( - 0, - TRUNCATE_LENGTH, - ); - } catch (e) { - contentText = "Complex content"; - } - } - - const truncatedContent = - contentText.length > TRUNCATE_LENGTH - ? contentText.substring(0, TRUNCATE_LENGTH) + "..." - : contentText; - - parts.push(`👤 **User request**: ${truncatedContent}`); - } else if (message.role === "assistant") { - // Assistant messages - could be string, array of content parts, or have tool_calls - if (typeof message.content === "string") { - const truncatedContent = - message.content.length > TRUNCATE_LENGTH - ? message.content.substring(0, TRUNCATE_LENGTH) + "..." - : message.content; - parts.push(`🧠 **AI thinking**: ${truncatedContent}`); - } else if (Array.isArray(message.content)) { - // Also include text content if present - const textItems = message.content - .map((item) => - item && item.type === "text" && typeof item.text === "string" - ? item.text - : undefined, - ) - .filter((x) => x !== undefined); - - if (textItems.length > 0) { - // Just take the first text for brevity - const firstText = textItems[0]; - const truncatedText = - firstText.length > TRUNCATE_LENGTH - ? firstText.substring(0, TRUNCATE_LENGTH) + "..." - : firstText; - parts.push(`💭 **AI thinking**: ${truncatedText}`); - } - } - } else if (message.role === "tool") { - const toolName = message.content.map((x) => x.toolName).join(", "); - parts.push(`🔄 **Tool response**: ${toolName}`); - } - } - - return parts.join("\n\n"); -} \ No newline at end of file diff --git a/examples/linear-coding-agent/src/workers/coding-agent/linear.ts b/examples/linear-coding-agent/src/workers/coding-agent/linear.ts deleted file mode 100644 index a92cd2315..000000000 --- a/examples/linear-coding-agent/src/workers/coding-agent/linear.ts +++ /dev/null @@ -1,716 +0,0 @@ -/** - * Linear utilities for the coding agent - */ -import { Comment, Issue, LinearClient } from "@linear/sdk"; -import { getConfig } from "../../config"; -import { Ctx, updateDebugState } from "../coding-agent/mod"; -import { IssueStatus, LinearWebhookEvent } from "./types"; -import { addBotComment } from "./linear-utils"; -import * as github from "./github"; -import * as llm from "./llm"; - -/** - * Handle new issue created event - */ -export async function handleIssueCreated( - c: Ctx, - event: LinearWebhookEvent, -): Promise { - try { - const issueId = event.data.id; - const issueFriendlyId = event.data.identifier; - updateDebugState(c, "Processing new issue", `received`, issueId); - - // Store issue ID in state - c.state.linear.issueId = issueId; - - // Set initial status in state and on Linear - c.state.linear.status = "In Progress"; - const initialStatusResult = await updateIssueStatus(c, issueId, "In Progress"); - - // Verify that the status update succeeded - if (initialStatusResult) { - console.log(`[LINEAR] ✅ Successfully updated issue status to "In Progress"`); - } else { - console.error(`[LINEAR] ❌ Failed to update issue status to "In Progress"`); - // Try one more time after a short delay - await new Promise(resolve => setTimeout(resolve, 1000)); - await updateIssueStatus(c, issueId, "In Progress"); - } - - console.log(`[LINEAR] New issue created: ${issueId}`); - - // Add an initial comment with eyes emoji to show we're working on it - await addBotComment( - c, - issueId, - `👀 Starting to work on this issue. I'll analyze your request and implement the necessary changes. Please stand by...`, - "info", - ); - - // Create a new branch for this issue - await createBranchForIssue( - c, - issueId, - issueFriendlyId, - event.data.title ?? "unknown", - ); - - // Fetch the repository file tree - await fetchFileTree(c); - - // Initialize LLM history if it's empty, or keep existing conversation - if (c.state.llm.history.length === 0) { - console.log(`[LINEAR] Initializing new LLM history for new issue`); - } else { - console.log(`[LINEAR] Keeping existing LLM history with ${c.state.llm.history.length} messages`); - } - - // Always clear modified files when starting with a new issue - c.state.code.modifiedFiles = {}; - - // Process the issue with LLM - let prompt = `Title: ${event.data.title}`; - if (event.data.description) - prompt += `\nDescription:\n${event.data.description}`; - await processIssueWithLLM(c, prompt); - - // Check if there are any modified files - const modifiedFilesCount = Object.keys(c.state.code.modifiedFiles).length; - if (modifiedFilesCount > 0) { - // Create PR for the changes - await createPRForIssue( - c, - issueId, - issueFriendlyId, - event.data.title ?? "unknown", - event.data.description ?? "unknown", - ); - - // Update status to In Review - console.log(`[LINEAR] Changing issue status to "In Review" after completing implementation`); - c.state.linear.status = "In Review"; - const reviewStatusResult = await updateIssueStatus(c, issueId, "In Review"); - - // Verify that the status update succeeded - if (reviewStatusResult) { - console.log(`[LINEAR] ✅ Successfully updated issue status to "In Review"`); - } else { - console.error(`[LINEAR] ❌ Failed to update issue status to "In Review"`); - - // Log current status for debugging - const currentStatus = await getIssueStatus(c, issueId); - console.log(`[LINEAR] Current issue status is: ${currentStatus}`); - - // Try one more time after a short delay - console.log(`[LINEAR] Trying status update again after a short delay...`); - - // Wait 1 second and try again - await new Promise(resolve => setTimeout(resolve, 1000)); - const secondAttemptResult = await updateIssueStatus(c, issueId, "In Review"); - - if (secondAttemptResult) { - console.log(`[LINEAR] ✅ Second attempt to update status succeeded`); - } else { - console.error(`[LINEAR] ❌ Second attempt to update status also failed`); - } - } - - // Add a comment to confirm status change - await addBotComment( - c, - issueId, - `✅ I've processed your issue and created a PR. The status has been changed to "In Review".`, - "success" - ); - } else { - console.log(`[LINEAR] No files were modified during processing`); - await addBotComment( - c, - issueId, - `⚠️ I processed your issue but no files were modified. Please provide more details or clarify the request.`, - "info" - ); - } - } catch (error) { - console.error(`[LINEAR] Failed to process issue creation:`, error); - updateDebugState( - c, - "Failed to process issue", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - - // Attempt to add a comment about the error - try { - if (c.state.linear.issueId) { - await addBotComment( - c, - c.state.linear.issueId, - `I encountered an error while processing this issue: ${error instanceof Error ? error.message : String(error)}\n\nPlease try again or contact support.`, - "failure", - ); - } - } catch (commentError) { - console.error(`[LINEAR] Failed to add error comment:`, commentError); - } - } -} - -/** - * Handle new comment created event - */ -export async function handleCommentCreated( - c: Ctx, - event: LinearWebhookEvent, -): Promise { - try { - // Detailed debug logging for webhook payload - console.log(`[LINEAR] Comment webhook payload:`, JSON.stringify(event.data)); - - // Skip if no issue or this is a bot comment - if (!event.data.issue) { - console.log(`[LINEAR] ⚠️ Skipping comment - no issue reference found in the event data`); - return; - } - - // Check if comment is from a bot user - if (event.data.user?.isBot) { - console.log(`[LINEAR] ⚠️ Skipping comment from bot user - preventing feedback loop`); - return; - } - - // Check if comment starts with a bot emoji (✅, ❌, 🤖) - // This catches comments created by our bot even if the isBot flag isn't set - if (event.data.body && (event.data.body.startsWith('✅') || - event.data.body.startsWith('❌') || - event.data.body.startsWith('🤖'))) { - console.log(`[LINEAR] ⚠️ Skipping comment that starts with bot emoji: "${event.data.body.substring(0, 20)}..."`); - return; - } - - const issueId = event.data.issue.id; - const commentId = event.data.id; - const commentBody = event.data.body || "(empty comment)"; - - updateDebugState(c, "Processing new comment", `received: "${commentBody.substring(0, 50)}${commentBody.length > 50 ? '...' : ''}"`, commentId); - console.log(`[LINEAR] New comment on issue ${issueId}: ${commentId} - Content: "${commentBody.substring(0, 100)}${commentBody.length > 100 ? '...' : ''}"`); - - // Store issue ID in state if not already stored - if (!c.state.linear.issueId) { - c.state.linear.issueId = issueId; - console.log(`[LINEAR] Setting issue ID in state: ${issueId}`); - } - - // Check the current issue status - const currentStatus = await getIssueStatus(c, issueId); - console.log(`[LINEAR] Current issue status: ${currentStatus}`); - - // Only process comments requesting changes if the issue is in review state - if (currentStatus !== "In Review") { - console.log(`[LINEAR] ⚠️ Issue status is "${currentStatus}", not processing comment as changes can only be requested during review.`); - await addBotComment( - c, - issueId, - `I can't process changes when the issue is in the "${currentStatus}" state. Comments requesting changes are only processed when the issue is in the "In Review" state.`, - "info", - ); - return; - } - - // Update the issue status back to "In Progress" when working on a comment - console.log(`[LINEAR] Changing issue status to "In Progress" to work on the comment`); - c.state.linear.status = "In Progress"; - const commentStatusResult = await updateIssueStatus(c, issueId, "In Progress"); - - // Verify that the status update succeeded - if (commentStatusResult) { - console.log(`[LINEAR] ✅ Successfully updated issue status to "In Progress"`); - } else { - console.error(`[LINEAR] ❌ Failed to update issue status to "In Progress"`); - // Try one more time after a short delay - await new Promise(resolve => setTimeout(resolve, 1000)); - await updateIssueStatus(c, issueId, "In Progress"); - } - - // Clear any previously modified files to start fresh - console.log(`[LINEAR] Clearing ${Object.keys(c.state.code.modifiedFiles).length} previously modified files from state`); - c.state.code.modifiedFiles = {}; - - // Fetch the repository file tree to make sure it's up to date - await fetchFileTree(c); - - // Add a comment to acknowledge the request - await addBotComment( - c, - issueId, - `👀 I'm working on your comment. I'll analyze it and make the requested changes. Please stand by...`, - "info", - ); - - // We want to keep the LLM history for context continuity - // Just log the current history size for debugging - console.log(`[LINEAR] Continuing LLM conversation with ${c.state.llm.history.length} existing messages`); - - // Keep the progress comment ID to allow real-time updates - // This lets the user see step-by-step progress for follow-up requests too - - // Process the comment with LLM - await processIssueWithLLM(c, event.data.body ?? "unknown"); - - // Check if there are modified files to commit - const modifiedFilesCount = Object.keys(c.state.code.modifiedFiles).length; - if (modifiedFilesCount > 0) { - // Commit the changes to GitHub - console.log(`[LINEAR] Committing ${modifiedFilesCount} modified files to GitHub`); - const issueFriendlyId = event.data.issue?.id || "unknown"; - const commitMessage = `Update implementation for ${issueFriendlyId} based on feedback`; - - // Create a commit for the changes - await github.commitChanges( - c, - c.state.code.modifiedFiles, - c.state.github.branchName, - commitMessage - ); - - // Reset the modified files state after committing - c.state.code.modifiedFiles = {}; - - console.log(`[LINEAR] Successfully committed changes to GitHub`); - } else { - console.log(`[LINEAR] No files were modified during processing`); - } - - // Change status back to "In Review" after processing - console.log(`[LINEAR] Changing issue status back to "In Review" after processing the comment`); - c.state.linear.status = "In Review"; - const finishStatusResult = await updateIssueStatus(c, issueId, "In Review"); - - // Verify that the status update succeeded - if (finishStatusResult) { - console.log(`[LINEAR] ✅ Successfully updated issue status to "In Review"`); - } else { - console.error(`[LINEAR] ❌ Failed to update issue status to "In Review"`); - - // Log current status for debugging - const currentStatus = await getIssueStatus(c, issueId); - console.log(`[LINEAR] Current issue status is: ${currentStatus}`); - - // Try one more time after a short delay - console.log(`[LINEAR] Trying status update again after a short delay...`); - - // Wait 1 second and try again - await new Promise(resolve => setTimeout(resolve, 1000)); - const secondAttemptResult = await updateIssueStatus(c, issueId, "In Review"); - - if (secondAttemptResult) { - console.log(`[LINEAR] ✅ Second attempt to update status succeeded`); - } else { - console.error(`[LINEAR] ❌ Second attempt to update status also failed`); - } - } - - // Add a comment to indicate completion - await addBotComment( - c, - issueId, - `✅ I've processed your comment and pushed the changes to the branch. Please review!`, - "success", - ); - } catch (error) { - console.error(`[LINEAR] Failed to process comment creation:`, error); - updateDebugState( - c, - "Failed to process comment", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - - // Attempt to add a comment about the error - try { - if (c.state.linear.issueId) { - await addBotComment( - c, - c.state.linear.issueId, - `I encountered an error while processing your comment: ${error instanceof Error ? error.message : String(error)}\n\nPlease try again or contact support.`, - "failure", - ); - } - } catch (commentError) { - console.error(`[LINEAR] Failed to add error comment:`, commentError); - } - } -} - -/** - * Handle issue updated event - */ -export async function handleIssueUpdated( - c: Ctx, - event: LinearWebhookEvent, -): Promise { - try { - const issueId = event.data.id; - - // Skip if there's no state change - if (!event.data.state) { - console.log(`[LINEAR] Skipping issue update (no state change)`); - return; - } - - // Get the new status name - const newStatus = event.data.state?.name as IssueStatus; - console.log(`[LINEAR] Issue ${issueId} status changed to: ${newStatus}`); - - // No longer checking if the status change was triggered by this bot - // We'll treat all status changes as external - updateDebugState( - c, - "Processing status update", - `changed to ${newStatus}`, - issueId, - ); - - // Store issue ID and status in state - c.state.linear.issueId = issueId; - c.state.linear.status = newStatus; - - // Handle based on new status - if (newStatus === "Done") { - // Issue is Done, merge the PR if we have one - if (c.state.github.prInfo) { - console.log( - `[LINEAR] Issue is Done, merging PR #${c.state.github.prInfo.number}`, - ); - - // Merge the PR - await github.mergePullRequest(c, c.state.github.prInfo.number); - - // Add a comment about the merge - await addBotComment( - c, - issueId, - `✅ The pull request has been merged as the issue is now marked as Done.`, - "success", - ); - } else { - console.log(`[LINEAR] Issue is Done but no PR exists to merge`); - } - } else if (newStatus === "Canceled") { - // Cancel any ongoing LLM process first - if (c.vars.llmAbortController) { - console.log( - `[LINEAR] Cancelling ongoing LLM request due to status change to ${newStatus}`, - ); - c.vars.llmAbortController.abort(); - c.vars.llmAbortController = undefined; - } - - // Issue is canceled, close the PR if we have one - if (c.state.github.prInfo && c.state.github.prInfo.number) { - console.log( - `[LINEAR] Issue is canceled, closing PR #${c.state.github.prInfo.number}`, - ); - - // Close the PR - await github.closePullRequest(c, c.state.github.prInfo.number); - - // Add a comment about closing the PR - await addBotComment( - c, - issueId, - `❌ The pull request has been closed as the issue is now marked as Canceled.`, - "info", - ); - } else { - console.log(`[LINEAR] Issue is canceled but no PR exists to close`); - } - } - } catch (error) { - console.error(`[LINEAR] Failed to process issue update:`, error); - updateDebugState( - c, - "Failed to process status update", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - } -} - -/** - * Create a new branch for an issue - */ -async function createBranchForIssue( - c: Ctx, - issueId: string, - issueFriendlyId: string, - title: string, -): Promise { - try { - // Create a branch name from issue ID and title - const formattedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - const branchName = `issue-${issueFriendlyId.toLowerCase()}-${formattedTitle.substring(0, 30)}`; - - updateDebugState(c, "Creating branch", branchName, issueFriendlyId); - console.log(`[LINEAR] Creating branch: ${branchName}`); - - // Create branch in GitHub - await github.createBranch(c, branchName, c.state.github.baseBranch); - - // Store the branch name in state - c.state.github.branchName = branchName; - - // Add a comment about the branch creation - await addBotComment( - c, - issueId, - `Created a new branch \`${branchName}\` for this issue.`, - "info", - ); - - console.log(`[LINEAR] Branch created successfully`); - } catch (error) { - console.error(`[LINEAR] Failed to create branch:`, error); - updateDebugState( - c, - "Failed to create branch", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - throw error; - } -} - -/** - * Fetch repository file tree - */ -async function fetchFileTree(c: Ctx): Promise { - try { - updateDebugState(c, "Fetching files", "Getting repository file tree"); - console.log(`[LINEAR] Fetching file tree from GitHub`); - - // Get file tree from GitHub - c.state.code.fileTree = await github.getFileTree( - c, - c.state.github.branchName, - ); - - console.log( - `[LINEAR] File tree fetched successfully: ${c.state.code.fileTree.length} files`, - ); - } catch (error) { - console.error(`[LINEAR] Failed to fetch file tree:`, error); - updateDebugState( - c, - "Failed to fetch files", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - throw error; - } -} - -/** - * Process an issue with LLM - */ -async function processIssueWithLLM(c: Ctx, prompt: string): Promise { - try { - updateDebugState( - c, - "Processing issue", - "generating code with LLM", - c.state.linear.issueId, - ); - console.log(`[LINEAR] Processing issue with LLM`); - - // Process with LLM - await llm.processWithLLM(c, prompt); - - console.log(`[LINEAR] LLM processing completed`); - } catch (error) { - console.error(`[LINEAR] Failed to process with LLM:`, error); - updateDebugState( - c, - "Failed to process with LLM", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - throw error; - } -} - -/** - * Create a PR for the issue - */ -async function createPRForIssue( - c: Ctx, - issueId: string, - issueFriendlyId: string, - title: string, - description: string, -): Promise { - try { - updateDebugState( - c, - "Creating PR", - "Creating pull request", - issueFriendlyId, - ); - console.log(`[LINEAR] Creating PR for issue ${issueId}`); - - // Check if we have any modified files - const modifiedFilesCount = Object.keys(c.state.code.modifiedFiles).length; - if (modifiedFilesCount === 0) { - console.log(`[LINEAR] No files modified, skipping PR creation`); - await addBotComment( - c, - issueId, - `No files were modified during processing, so no PR was created.`, - "info", - ); - return; - } - - // First commit the changes - await github.commitChanges( - c, - c.state.code.modifiedFiles, - c.state.github.branchName, - `Implement changes for ${issueFriendlyId}: ${title}`, - ); - - // Generate a summary of the changes - const summary = await llm.generateChangeSummary(c, description); - - // Create PR - c.state.github.prInfo = await github.createPullRequest( - c, - `${title}`, // Just use the issue title - `Closes ${issueFriendlyId}\n\nImplements changes requested in Linear issue.\n\n${summary}\n\n*Authored by RivetKit Coding Agent*`, // Include "Closes" keyword - c.state.github.branchName, - c.state.github.baseBranch, - ); - - console.log( - `[LINEAR] PR created successfully: #${c.state.github.prInfo?.number}`, - ); - - // Add a comment with the PR link - await addBotComment( - c, - issueId, - `✅ Created PR #${c.state.github.prInfo?.number}: ${c.state.github.prInfo?.url}\n\nSummary of changes:\n${summary}`, - "success", - ); - } catch (error) { - console.error(`[LINEAR] Failed to create PR:`, error); - updateDebugState( - c, - "Failed to create PR", - error instanceof Error ? error.message : String(error), - undefined, - "failure", - ); - throw error; - } -} - -/** - * Update an issue's status - */ -export async function updateIssueStatus( - c: Ctx, - issueId: string, - status: IssueStatus, -): Promise { - try { - updateDebugState(c, "Updating issue status", `to ${status}`, issueId); - console.log(`[LINEAR] Updating issue ${issueId} status to: ${status}`); - - const config = getConfig(); - const client = new LinearClient({ apiKey: config.linearApiKey }); - - // Get the organization's workflow states - const workflowStates = await client.workflowStates(); - - // Log all available workflow states for debugging - console.log(`[LINEAR] Available workflow states: ${workflowStates.nodes.map(state => state.name).join(', ')}`); - - // Find the matching state by name - const statusState = workflowStates.nodes.find( - (state) => state.name === status, - ); - - if (!statusState) { - console.error(`[LINEAR] 🚨 Error: Could not find workflow state for status: "${status}"`); - console.error(`[LINEAR] Available states are: ${workflowStates.nodes.map(state => `"${state.name}"`).join(', ')}`); - throw new Error(`Could not find workflow state for status: ${status}`); - } - - console.log(`[LINEAR] Found matching workflow state: ${statusState.name} (${statusState.id})`); - - // Update the issue with the new status - const issue = await client.issue(issueId); - - // Log current state before updating - const currentState = await issue.state; - console.log(`[LINEAR] Current issue state before update: ${currentState?.name || 'unknown'}`); - - // Perform the update - const updateResult = await issue.update({ stateId: statusState.id }); - console.log(`[LINEAR] Update result:`, updateResult); - - // No longer recording bot-initiated changes - console.log(`[LINEAR] Changed issue status to: ${status}`); - - return true; - } catch (error) { - console.error(`[LINEAR] 🚨 Failed to update issue ${issueId} status to "${status}":`, error); - if (error instanceof Error) { - console.error(`[LINEAR] Error details: ${error.message}`); - if (error.stack) { - console.error(`[LINEAR] Stack trace: ${error.stack}`); - } - } - return false; - } -} - -/** - * Get the current status of an issue - */ -export async function getIssueStatus( - c: Ctx, - issueId: string, -): Promise { - try { - console.log(`[LINEAR] Getting status for issue ${issueId}`); - - const config = getConfig(); - const client = new LinearClient({ apiKey: config.linearApiKey }); - - // Get the issue - const issue = await client.issue(issueId); - - // Get the status name safely - let statusName: IssueStatus = "Unknown"; - const state = await issue.state; - if (state?.name) { - statusName = state.name; - } - - console.log(`[LINEAR] Issue ${issueId} status: ${statusName}`); - - return statusName; - } catch (error) { - console.error(`[LINEAR] Failed to get issue status:`, error); - return null; - } -} diff --git a/examples/linear-coding-agent/src/workers/coding-agent/llm.ts b/examples/linear-coding-agent/src/workers/coding-agent/llm.ts deleted file mode 100644 index 521fdc8a5..000000000 --- a/examples/linear-coding-agent/src/workers/coding-agent/llm.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { generateText, tool } from "ai"; -import { anthropic } from "@ai-sdk/anthropic"; -import { - CoreAssistantMessage, - CoreSystemMessage, - CoreToolMessage, - CoreUserMessage, -} from "ai"; -import { z } from "zod"; -import type { GitHubFile, LLMToolResult } from "./types"; -import { Ctx } from "./mod"; -import * as github from "./github"; -import { addBotComment, updateBotComment, formatLLMMessagesForComment } from "./linear-utils"; - -// Type alias using AI SDK's native message types -export type LLMMessage = - | CoreSystemMessage - | CoreUserMessage - | CoreAssistantMessage - | CoreToolMessage; -import { getConfig } from "../../config"; - -/** - * Process issue/comment with LLM - */ -export async function processWithLLM(c: Ctx, prompt: string) { - // Cancel any existing LLM request first - if (c.vars.llmAbortController) { - console.log( - "[LLM] Cancelling existing LLM request before starting a new one", - ); - c.vars.llmAbortController.abort(); - c.vars.llmAbortController = undefined; - } - - console.log( - `[LLM] Starting processing with prompt length: ${prompt.length} chars`, - ); - - // For follow-up requests, we want to continue updating the same comment - // to show real-time progress, just like the first request - if (c.state.linear.llmProgressCommentId) { - console.log( - "[LLM] Using existing progress comment ID for real-time updates:", - c.state.linear.llmProgressCommentId - ); - } else { - console.log("[LLM] No existing progress comment ID - will create a new one"); - } - - // Create tool handlers using context - const toolHandlers = createToolHandlers(c, async (path, branch) => { - return await github.readFile(c, path, branch); - }); - - // Process the issue with LLM - try { - await processIssue(c, prompt, toolHandlers); - - console.log( - `[LLM] Processing completed successfully. Modified files: ${Object.keys(c.state.code.modifiedFiles).length}`, - ); - - return { success: true }; - } catch (error) { - console.error("[LLM] Processing failed:", error); - return { success: false }; - } -} - -/** - * Interface for tool handlers to be used with LLM - */ -export interface ToolHandlers { - listFileTree: () => Promise; - readFiles: (paths: string[]) => Promise; - modifyFiles: (files: Record) => Promise; -} - -/** - * Create handler functions for LLM tools using context - */ -export function createToolHandlers( - c: Ctx, - readFileFn: ( - path: string, - branch: string, - ) => Promise<{ content: string } | null>, -): ToolHandlers { - return { - // Handler for listing file tree - listFileTree: async (): Promise => { - console.log("[LLM] Tool called: listFileTree"); - return { - success: true, - result: c.state.code.fileTree, - }; - }, - - // Handler for reading files - readFiles: async (paths: string[]): Promise => { - console.log(`[LLM] Tool called: readFiles (${paths.length} files)`); - const result: Record = {}; - - for (const path of paths) { - // First check if we have a modified version in state - if (path in c.state.code.modifiedFiles) { - result[path] = c.state.code.modifiedFiles[path]; - continue; - } - - // Otherwise read from GitHub - const fileContent = await readFileFn(path, c.state.github.branchName); - if (fileContent) { - result[path] = fileContent.content; - } else { - result[path] = null; - } - } - - return { - success: true, - result, - }; - }, - - // Handler for modifying files - modifyFiles: async ( - files: Record, - ): Promise => { - console.log( - `[LLM] Tool called: modifyFiles (${Object.keys(files).length} files)`, - ); - // Update state with the modified files - for (const [path, content] of Object.entries(files)) { - c.state.code.modifiedFiles[path] = content; - } - - return { - success: true, - result: { modifiedFiles: Object.keys(files) }, - }; - }, - }; -} - -/** - * Define all tools as raw Vercel AI tools - */ -export function createTools(handlers: ToolHandlers) { - return { - listFileTree: tool({ - description: "List the file structure of the repository", - parameters: z.object({}), - execute: async () => { - const result = await handlers.listFileTree(); - if (!result.success) { - throw new Error(result.error || "Unknown error from listFileTree"); - } - return JSON.stringify(result.result); - }, - }), - - readFiles: tool({ - description: "Read the contents of specified files", - parameters: z.object({ - paths: z.array(z.string()).describe("Array of file paths to read"), - }), - execute: async ({ paths }) => { - const result = await handlers.readFiles(paths); - if (!result.success) { - throw new Error(result.error || "Unknown error from readFiles"); - } - return JSON.stringify(result.result); - }, - }), - - modifyFiles: tool({ - description: "Modify the contents of specified files", - parameters: z.object({ - files: z - .record(z.string()) - .describe("Object mapping file paths to their new contents"), - }), - execute: async ({ files }) => { - const result = await handlers.modifyFiles(files); - if (!result.success) { - throw new Error(result.error || "Unknown error from modifyFiles"); - } - return JSON.stringify(result.result); - }, - }), - }; -} - -/** - * System message for coding agent - */ -export const CODING_AGENT_SYSTEM_MESSAGE = `You are an expert software engineer tasked with implementing code changes based on Linear issue descriptions. -You have access to a GitHub repository and can view and modify files. Make the minimal necessary changes to -correctly implement the requirements in the issue. Focus on writing clean, maintainable code that follows -the project's style and best practices. You will be given tools to explore the codebase and make changes.`; - -/** - * Generate code changes based on a Linear issue - */ -export async function processIssue( - c: Ctx, - prompt: string, - toolHandlers: ToolHandlers, -): Promise { - // Initialize variables to track success - let success = true; - let lastError: string | null = null; - - try { - const config = getConfig(); - console.log("[LLM] Setting up model and tools"); - - // Setup AI model - const aiModel = anthropic("claude-3-7-sonnet-20250219"); - - // Create tools using the new simplified approach - const tools = createTools(toolHandlers); - - // Create a new abort controller and store it in vars - const abortController = new AbortController(); - c.vars.llmAbortController = abortController; - - // If starting a new conversation, add the system message and create initial user message - let userMessage = prompt; - if (c.state.llm.history.length === 0) { - console.log("[LLM] Starting new conversation"); - - // Add system message to conversation history - c.state.llm.history.push({ - role: "system", - content: CODING_AGENT_SYSTEM_MESSAGE, - }); - - // Create a more detailed prompt for new conversations - userMessage = `I need you to implement the following Linear issue:\n\n${prompt}\n\nPlease start by exploring the codebase to understand its structure, then make the necessary changes to implement this feature.`; - } else { - console.log( - `[LLM] Continuing conversation with ${c.state.llm.history.length} messages`, - ); - } - - // Add user message to conversation history - c.state.llm.history.push({ - role: "user", - content: userMessage, - }); - - // Create message array for LLM request - const conversationMessages = [...c.state.llm.history]; - - console.log( - `[LLM] Sending request to Claude (${conversationMessages.length} messages)`, - ); - - // Use Vercel AI SDK's generateText with our tools - const startTime = Date.now(); - const { response } = await generateText({ - model: aiModel, - messages: conversationMessages, - tools, - abortSignal: abortController.signal, - maxSteps: 32, - maxTokens: 64_000, - onStepFinish: async ({ response }) => { - // Get the current history length to determine which messages are new - const currentHistoryLength = c.state.llm.history.length; - - // Only add messages that aren't already in the history (new messages) - const newMessages = response.messages.slice(currentHistoryLength); - - console.log( - `👀 [LLM] Step finished (${response.messages.length} total messages, ${newMessages.length} new)`, - ); - - if (newMessages.length > 0) { - console.log( - `👀 [LLM] Adding ${newMessages.length} new messages to history (${newMessages.map((m) => m.role).join(",")})`, - ); - c.state.llm.history.push(...newMessages); - - // Update the progress comment with all messages - try { - // Format the entire history into a readable comment - const commentText = formatLLMMessagesForComment(c.state.llm.history); - - // If we already have a comment ID, update that comment - if (c.state.linear.llmProgressCommentId && typeof c.state.linear.llmProgressCommentId === 'string' && c.state.linear.llmProgressCommentId.length > 0) { - console.log(`👀 [LLM] Updating existing progress comment ${c.state.linear.llmProgressCommentId}`); - const updateResult = await updateBotComment( - c.state.linear.llmProgressCommentId, - `AI is working on your request:\n\n${commentText}`, - "info" - ); - - if (!updateResult) { - console.log(`⚠️ [LLM] Failed to update comment, will create a new one`); - // If update fails, try creating a new comment - const newResult = await addBotComment( - c, - c.state.linear.issueId, - `AI is working on your request:\n\n${commentText}`, - "info" - ); - - if (newResult.success && newResult.commentId && typeof newResult.commentId === 'string' && newResult.commentId.length > 0) { - c.state.linear.llmProgressCommentId = newResult.commentId; - console.log(`👀 [LLM] Saved new progress comment ID: ${newResult.commentId}`); - } else { - console.warn(`⚠️ [LLM] Got invalid replacement comment ID from Linear: ${newResult.commentId}`); - } - } - } else { - // Otherwise, create a new comment and save its ID - console.log(`👀 [LLM] Creating new progress comment`); - const result = await addBotComment( - c, - c.state.linear.issueId, - `AI is working on your request:\n\n${commentText}`, - "info" - ); - - if (result.success && result.commentId && typeof result.commentId === 'string' && result.commentId.length > 0) { - // Make sure we're setting a valid string ID - c.state.linear.llmProgressCommentId = result.commentId; - console.log(`👀 [LLM] Saved progress comment ID: ${result.commentId}`); - } else { - console.warn(`⚠️ [LLM] Got invalid comment ID from Linear: ${result.commentId}`); - } - } - } catch (error) { - console.error( - `[LLM] Failed to update progress comment: ${error}`, - ); - } - } - }, - }); - const duration = Date.now() - startTime; - - console.log( - `[LLM] Received response (${response.messages.length} ${response.messages.map((x) => x.role).join(",")} messages) in ${duration}ms`, - ); - - // Update the progress comment with completion message - try { - // Format the entire history into a readable comment - const commentText = formatLLMMessagesForComment(c.state.llm.history); - - if (c.state.linear.llmProgressCommentId && typeof c.state.linear.llmProgressCommentId === 'string' && c.state.linear.llmProgressCommentId.length > 0) { - // Update the existing comment - console.log(`👀 [LLM] Updating progress comment with completion status: ${c.state.linear.llmProgressCommentId}`); - const updateResult = await updateBotComment( - c.state.linear.llmProgressCommentId, - `AI has finished processing your request:\n\n${commentText}\n\n✅ Processing complete - preparing results...`, - "success" - ); - - if (!updateResult) { - console.log(`⚠️ [LLM] Failed to update completion comment, will create a new one`); - // If update fails, create a new comment - await addBotComment( - c, - c.state.linear.issueId, - `AI has finished processing your request:\n\n${commentText}\n\n✅ Processing complete - preparing results...`, - "success" - ); - } - } else { - // If no comment ID exists (rare case), create a new one - console.log(`👀 [LLM] Creating completion comment`); - await addBotComment( - c, - c.state.linear.issueId, - `AI has finished processing your request:\n\n${commentText}\n\n✅ Processing complete - preparing results...`, - "success" - ); - } - } catch (error) { - console.error( - `[LLM] Failed to update completion comment: ${error}`, - ); - } - - // Count tool calls - let toolCallCount = 0; - response.messages.forEach((msg) => { - if ( - msg.role === "assistant" && - "tool_calls" in msg && - Array.isArray(msg.tool_calls) - ) { - toolCallCount += msg.tool_calls.length || 0; - } - }); - - if (toolCallCount > 0) { - console.log(`[LLM] Used ${toolCallCount} tool calls during processing`); - } - } catch (error: unknown) { - // Check if this was an abort error - if (error instanceof Error && error.name === "AbortError") { - console.warn("[LLM] Processing was aborted"); - success = false; - lastError = "Request was aborted"; - } else { - console.error("[LLM] Error in service:", error); - success = false; - lastError = error instanceof Error ? error.message : String(error); - - // If there was an error, inform the LLM about it - if (lastError) { - console.log("[LLM] Adding error message to conversation history"); - c.state.llm.history.push({ - role: "user", - content: `There was an error in your implementation: ${lastError}. Please fix it and try again.`, - }); - } - } - } - - // Clear the abort controller - c.vars.llmAbortController = undefined; - console.log(`[LLM] Processing finished, success: ${success}`); -} - -/** - * Generate a summary of the changes made - */ -export async function generateChangeSummary( - c: Ctx, - issueDescription: string, -): Promise { - // Cancel any existing LLM request first - if (c.vars.llmAbortController) { - console.log( - "[LLM] Cancelling existing LLM request before generating summary", - ); - c.vars.llmAbortController.abort(); - c.vars.llmAbortController = undefined; - } - - console.log("[LLM] Generating change summary"); - - // Create a new abort controller and store it in vars - const abortController = new AbortController(); - c.vars.llmAbortController = abortController; - - const modifiedFilePaths = Object.keys(c.state.code.modifiedFiles); - const config = getConfig(); - - // Set API key through environment variable - const aiModel = anthropic("claude-3-7-sonnet-20250219"); - - try { - // Define system message for change summary - const SUMMARY_SYSTEM_MESSAGE = - "You are a helpful assistant that summarizes code changes made to implement a feature. Provide a concise technical summary followed by a brief impact statement."; - - console.log( - `[LLM] Requesting change summary for ${modifiedFilePaths.length} files`, - ); - - // Use Vercel AI SDK's generateText for change summary - const startTime = Date.now(); - const summary = await generateText({ - model: aiModel, - messages: [ - { role: "system", content: SUMMARY_SYSTEM_MESSAGE }, - { - role: "user", - content: `I've implemented the following Linear issue:\n\n${issueDescription}\n\nI modified these files: ${modifiedFilePaths.join(", ")}. Please provide a concise summary of the changes that were likely made to implement this feature. Keep it brief and technical, focusing on what was accomplished.`, - }, - ], - temperature: 0.7, // Add a bit of temperature for more natural-sounding summaries - abortSignal: abortController.signal, - }); - const duration = Date.now() - startTime; - - console.log(`[LLM] Summary generated in ${duration}ms`); - - // Clear the controller after successful completion - c.vars.llmAbortController = undefined; - - return summary.text; - } catch (error: unknown) { - // Handle abortion or other errors - if (error instanceof Error && error.name === "AbortError") { - console.warn("[LLM] Summary generation was aborted"); - return "Generation aborted"; - } - - console.error("[LLM] Error generating summary:", error); - return "Error generating summary"; - } -} diff --git a/examples/linear-coding-agent/src/workers/coding-agent/mod.ts b/examples/linear-coding-agent/src/workers/coding-agent/mod.ts deleted file mode 100644 index c220117cf..000000000 --- a/examples/linear-coding-agent/src/workers/coding-agent/mod.ts +++ /dev/null @@ -1,599 +0,0 @@ -import { type ActionContextOf, type WorkerContextOf, worker } from "rivetkit"; -import type { - CodingAgentState, - CodingAgentVars, - LinearWebhookEvent, - QueuedRequest, - RequestType, -} from "./types"; -import { getConfig } from "../../config"; -import * as github from "./github"; -import * as linear from "./linear"; -import * as llm from "./llm"; -import { LLMMessage } from "./llm"; -import { randomUUID } from "crypto"; -import { - handleIssueCreated, - handleCommentCreated, - handleIssueUpdated, - getIssueStatus -} from "./linear"; - -export type Ctx = - | WorkerContextOf - | ActionContextOf; - -/** - * Update the debug state - */ -export function updateDebugState(c: Ctx, operation: string, stage: string, requestId?: string, status: 'working' | 'success' | 'failure' = 'working'): void { - // Safety check - ensure state and debug objects exist - try { - if (c.state && typeof c.state === 'object') { - // Initialize debug object if it doesn't exist - if (!c.state.debug) { - c.state.debug = { - currentOperation: "initializing", - lastUpdated: Date.now(), - stage: "starting", - requestId: undefined - }; - } - - // Update the debug state - c.state.debug = { - currentOperation: operation, - stage: stage, - lastUpdated: Date.now(), - requestId: requestId - }; - } - } catch (error) { - console.warn(`[DEBUG] Unable to update debug state: ${error}`); - } - - // Choose emoji based on status - let emoji = '👀'; // Default for working - if (status === 'success') { - emoji = '✅'; - } else if (status === 'failure') { - emoji = '❌'; - } - - // Log with appropriate emoji to indicate status - console.log(`${emoji} [DEBUG] ${operation} - ${stage}${requestId ? ` (${requestId})` : ''}`); -} - -/** - * Process a queued request - */ -async function processRequest(c: Ctx, request: QueuedRequest): Promise { - if (!request || !request.id || !request.type) { - console.error(`❌ [QUEUE] Invalid request object:`, request); - return; - } - - console.log(`🤖 [QUEUE] Processing request: ${request.id} (${request.type})`); - - try { - // Mark request as processing - request.status = 'processing'; - - // Update debug state with working indicator - updateDebugState(c, `Processing ${request.type}`, 'starting', request.id, 'working'); - - // Make sure data is available - if (!request.data) { - throw new Error(`Request data is missing for ${request.id}`); - } - - // Enhanced logging with request details - if (request.type === 'commentCreated') { - console.log(`🔍 [QUEUE] Comment details:`, { - id: request.data.data.id, - body: request.data.data.body?.substring(0, 100) + (request.data.data.body && request.data.data.body.length > 100 ? '...' : ''), - issueId: request.data.data.issue?.id, - userIsBot: request.data.data.user?.isBot, - action: request.data.action - }); - } - - // Process based on request type - switch (request.type) { - case 'issueCreated': - console.log(`🤖 [QUEUE] Bot is handling a new issue creation event`); - await handleIssueCreated(c, request.data); - break; - case 'commentCreated': - console.log(`🤖 [QUEUE] Bot is processing a new comment on an issue`); - - // Log the status of the issue referenced in the comment - if (request.data.data.issue?.id) { - try { - const currentStatus = await getIssueStatus(c, request.data.data.issue.id); - console.log(`🤖 [QUEUE] Issue status for the comment: ${currentStatus}`); - } catch (err) { - console.warn(`🤖 [QUEUE] Could not fetch issue status: ${err}`); - } - } - - await handleCommentCreated(c, request.data); - console.log(`🤖 [QUEUE] Finished processing comment`); - break; - case 'issueUpdated': - console.log(`🤖 [QUEUE] Bot is handling an issue status update`); - await handleIssueUpdated(c, request.data); - break; - default: - console.log(`❌ [QUEUE] Unknown request type: ${request.type}`); - throw new Error(`Unknown request type: ${request.type}`); - } - - // Mark request as completed - request.status = 'completed'; - updateDebugState(c, `Completed ${request.type}`, 'finished', request.id, 'success'); - console.log(`✅ [QUEUE] Request completed successfully: ${request.id} (${request.type})`); - } catch (error) { - // Mark request as failed - try { - request.status = 'failed'; - request.error = error instanceof Error ? error.message : String(error); - } catch (e) { - // In case updating the request itself fails - console.error(`❌ [QUEUE] Could not update request status:`, e); - } - - updateDebugState(c, `Failed ${request.type}`, 'error', request.id, 'failure'); - console.error(`❌ [QUEUE] Request failed: ${request.id} (${request.type})`, error); - } -} - -/** - * Process the queue - */ -async function processQueue(c: Ctx): Promise { - try { - // Safety check - ensure queue exists - if (!c.state.queue) { - console.warn(`🤖 [QUEUE] Queue not initialized in state, initializing now`); - c.state.queue = { - requests: [], - isProcessing: false, - lastProcessed: 0 - }; - } - - // Safety check - ensure requests array exists - if (!Array.isArray(c.state.queue.requests)) { - console.warn(`🤖 [QUEUE] Queue requests not initialized in state, initializing now`); - c.state.queue.requests = []; - } - - console.log(`🤖 [QUEUE] Starting queue processing, ${c.state.queue.requests.length} items in queue`); - - // Mark as processing - c.state.queue.isProcessing = true; - updateDebugState(c, "Queue processing", "starting"); - - try { - // Process each pending request in order - while (true) { - // Safety check - ensure requests array still exists - if (!Array.isArray(c.state.queue.requests)) { - console.warn(`🤖 [QUEUE] Queue requests array was lost, recreating`); - c.state.queue.requests = []; - break; - } - - // Find the next pending request - const nextRequest = c.state.queue.requests.find(r => r.status === 'pending'); - - // If no pending requests, exit the loop - if (!nextRequest) { - updateDebugState(c, "Queue processing", "no more pending requests", undefined, 'success'); - console.log(`🤖 [QUEUE] No more pending requests to process`); - break; - } - - // Update debug before processing - console.log(`🤖 [QUEUE] Preparing to process ${nextRequest.type} request (${nextRequest.id})`); - updateDebugState(c, "Queue processing", `preparing to process ${nextRequest.type}`, nextRequest.id); - - // Process the request - await processRequest(c, nextRequest); - - // Update last processed timestamp - c.state.queue.lastProcessed = Date.now(); - console.log(`🤖 [QUEUE] Updated last processed timestamp to ${new Date(c.state.queue.lastProcessed).toISOString()}`); - } - } finally { - // Mark as not processing - c.state.queue.isProcessing = false; - updateDebugState(c, "Queue processing", "finished", undefined, 'success'); - console.log(`✅ [QUEUE] Finished queue processing`); - } - } catch (error) { - console.error(`❌ [QUEUE] Fatal error in queue processing: ${error}`); - - // Try to reset the processing flag even in case of errors - try { - if (c.state && c.state.queue) { - c.state.queue.isProcessing = false; - } - } catch (e) { - console.error(`❌ [QUEUE] Could not reset processing flag: ${e}`); - } - - updateDebugState(c, "Queue processing", `fatal error: ${error}`, undefined, 'failure'); - } -} - -/** - * Start queue processing if not already processing - */ -function startQueueProcessing(c: Ctx): void { - try { - // Safety check - ensure queue exists - if (!c.state.queue) { - console.warn(`🤖 [QUEUE] Queue not initialized in state, initializing now`); - c.state.queue = { - requests: [], - isProcessing: false, - lastProcessed: 0 - }; - } - - // Safety check - ensure requests array exists - if (!Array.isArray(c.state.queue.requests)) { - console.warn(`🤖 [QUEUE] Queue requests not initialized in state, initializing now`); - c.state.queue.requests = []; - } - - // Log current queue state - const pendingCount = c.state.queue.requests.filter(r => r.status === 'pending').length; - const processingCount = c.state.queue.requests.filter(r => r.status === 'processing').length; - const completedCount = c.state.queue.requests.filter(r => r.status === 'completed').length; - const failedCount = c.state.queue.requests.filter(r => r.status === 'failed').length; - - console.log(`🤖 [QUEUE] Queue status: ${pendingCount} pending, ${processingCount} processing, ${completedCount} completed, ${failedCount} failed`); - - // Log details of pending requests for debugging - if (pendingCount > 0) { - const pendingRequests = c.state.queue.requests.filter(r => r.status === 'pending'); - console.log(`🤖 [QUEUE] Pending requests details:`); - pendingRequests.forEach(req => { - console.log(` - ID: ${req.id}, Type: ${req.type}, Event data: ${JSON.stringify({ - id: req.data.data.id, - type: req.data.type, - action: req.data.action, - issue: req.data.data.issue?.id, - hasUser: !!req.data.data.user, - isBot: req.data.data.user?.isBot - })}`); - }); - } - - // If already processing, return - if (c.state.queue.isProcessing) { - console.log(`🤖 [QUEUE] Queue is already being processed - no action needed`); - updateDebugState(c, "Queue start attempt", "already processing"); - return; - } - - // If no pending requests, return - if (!c.state.queue.requests.some(r => r.status === 'pending')) { - console.log(`🤖 [QUEUE] No pending requests to process - queue remains idle`); - updateDebugState(c, "Queue start attempt", "no pending requests"); - return; - } - - console.log(`🤖 [QUEUE] Bot is starting queue processing`); - updateDebugState(c, "Queue starting", "initiating"); - - // Start processing - c.vars.queueProcessingPromise = processQueue(c); - console.log(`🤖 [QUEUE] Queue processor started in background`); - } catch (error) { - console.error(`🤖 [QUEUE] Error starting queue processing: ${error}`); - updateDebugState(c, "Queue error", `Failed to start queue: ${error}`, undefined, 'failure'); - } -} - -/** - * Add request to queue - */ -function enqueueRequest(c: Ctx, type: RequestType, data: LinearWebhookEvent): string { - try { - // Safety check - ensure queue exists - if (!c.state.queue) { - console.warn(`🤖 [QUEUE] Queue not initialized in state, initializing now`); - c.state.queue = { - requests: [], - isProcessing: false, - lastProcessed: 0 - }; - } - - // Safety check - ensure requests array exists - if (!Array.isArray(c.state.queue.requests)) { - console.warn(`🤖 [QUEUE] Queue requests not initialized in state, initializing now`); - c.state.queue.requests = []; - } - - // Create request with unique ID - const requestId = randomUUID(); - console.log(`🤖 [QUEUE] Creating new ${type} request with ID: ${requestId}`); - - const request: QueuedRequest = { - id: requestId, - type, - timestamp: Date.now(), - data, - status: 'pending' - }; - - // Add to queue - c.state.queue.requests.push(request); - console.log(`🤖 [QUEUE] Added request to queue: ${requestId} (${type})`); - updateDebugState(c, "Request enqueued", `added ${type} request to queue`, requestId); - - // Log queue status after adding - const pendingCount = c.state.queue.requests.filter(r => r.status === 'pending').length; - console.log(`🤖 [QUEUE] Queue now has ${pendingCount} pending requests`); - - // Start processing if not already - console.log(`🤖 [QUEUE] Attempting to start queue processing`); - startQueueProcessing(c); - - return requestId; - } catch (error) { - console.error(`🤖 [QUEUE] Error enqueueing request: ${error}`); - updateDebugState(c, "Queue error", `Failed to enqueue request: ${error}`, undefined, 'failure'); - return randomUUID(); // Return a fallback ID - } -} - -export const codingAgent = worker({ - // Initialize state - state: { - // Linear issue information - linear: { - issueId: "", - status: "In Progress", - llmProgressCommentId: null, // Using null instead of undefined for better serialization - }, - - // GitHub repository information - github: { - owner: "", - repo: "", - baseBranch: "main", - branchName: "", - prInfo: null, - }, - - // Source code state - code: { - fileTree: [], - modifiedFiles: {}, - }, - - // LLM conversation history - llm: { - history: [] as LLMMessage[], - }, - - // Request queue - queue: { - requests: [], - isProcessing: false, - lastProcessed: 0 - }, - - // Debug information - debug: { - currentOperation: "initializing", - lastUpdated: Date.now(), - stage: "starting", - requestId: undefined - } - } as CodingAgentState, - - // Initialize variables (non-persisted) - createVars: () => { - return {} as CodingAgentVars; - }, - - // Handle actor instantiation - onCreate: (c) => { - console.log(`[ACTOR] Created actor instance with key: ${JSON.stringify(c.key)}`); - updateDebugState(c, "Actor created", `with key: ${JSON.stringify(c.key)}`); - }, - - // Handle actor start - onStart: (c) => { - console.log(`[ACTOR] Starting actor instance`); - updateDebugState(c, "Actor starting", "initialization"); - - // Safety check - ensure queue exists - if (!c.state.queue) { - console.warn(`[ACTOR] Queue not initialized in state, initializing now`); - c.state.queue = { - requests: [], - isProcessing: false, - lastProcessed: 0 - }; - } - - // Safety check - ensure requests array exists - if (!Array.isArray(c.state.queue.requests)) { - console.warn(`[ACTOR] Queue requests not initialized in state, initializing now`); - c.state.queue.requests = []; - } - - // Resume queue processing if there are pending requests - try { - if (c.state.queue.requests.some(r => r.status === 'pending')) { - console.log(`[ACTOR] Found pending requests in queue, resuming processing`); - updateDebugState(c, "Actor starting", "resuming queue processing"); - startQueueProcessing(c); - } else { - updateDebugState(c, "Actor starting", "no pending requests"); - } - } catch (error) { - console.error(`[ACTOR] Error checking queue: ${error}`); - updateDebugState(c, "Actor starting", "error checking queue", undefined, 'failure'); - } - }, - - // Define actions - actions: { - /** - * Initialize the agent with repository settings - */ - initialize: (c) => { - try { - updateDebugState(c, "Initializing agent", "loading config"); - - // Load config from environment variables - const config = getConfig(); - - updateDebugState(c, "Initializing agent", "storing repository settings"); - - // Store repository settings in state - c.state.github.owner = config.repoOwner; - c.state.github.repo = config.repoName; - c.state.github.baseBranch = config.baseBranch; - - console.log(`[ACTOR] Initialized actor with repository: ${config.repoOwner}/${config.repoName} (${config.baseBranch})`); - updateDebugState(c, "Initialized agent", `with repository: ${config.repoOwner}/${config.repoName}`); - - return {}; - } catch (error) { - console.error('[ACTOR] Initialization failed:', error); - updateDebugState(c, "Initialization failed", error instanceof Error ? error.message : String(error)); - throw error; - } - }, - - /** - * Get queue status - */ - getQueueStatus: (c) => { - updateDebugState(c, "Getting queue status", "calculating stats"); - - const pendingCount = c.state.queue.requests.filter(r => r.status === 'pending').length; - const processingCount = c.state.queue.requests.filter(r => r.status === 'processing').length; - const completedCount = c.state.queue.requests.filter(r => r.status === 'completed').length; - const failedCount = c.state.queue.requests.filter(r => r.status === 'failed').length; - - // Determine status emoji - let statusEmoji = '🤖'; - let statusMessage = 'Bot is idle'; - - if (c.state.queue.isProcessing) { - statusEmoji = '👀'; - statusMessage = 'Bot is actively processing requests'; - } else if (pendingCount > 0) { - statusEmoji = '⏳'; - statusMessage = 'Bot is waiting to process requests'; - } else if (failedCount > 0 && completedCount === 0) { - statusEmoji = '❌'; - statusMessage = 'Bot encountered errors with all requests'; - } else if (failedCount > 0) { - statusEmoji = '⚠️'; - statusMessage = 'Bot completed some requests with errors'; - } else if (completedCount > 0) { - statusEmoji = '✅'; - statusMessage = 'Bot completed all requests successfully'; - } - - return { - pendingCount, - processingCount, - completedCount, - failedCount, - totalCount: c.state.queue.requests.length, - isProcessing: c.state.queue.isProcessing, - lastProcessed: c.state.queue.lastProcessed ? new Date(c.state.queue.lastProcessed).toISOString() : null, - // Status and emojis - statusEmoji, - statusMessage, - // Include debug info - debug: { - ...c.state.debug, - lastUpdatedFormatted: new Date(c.state.debug.lastUpdated).toISOString() - } - }; - }, - - /** - * Handle issue creation event - */ - issueCreated: async (c, event: LinearWebhookEvent) => { - console.log(`[ACTOR] Received issue creation event: ${event.data.id} - ${event.data.title}`); - updateDebugState(c, "Received issue creation event", `${event.data.title}`, event.data.id); - - // Add to queue - const requestId = enqueueRequest(c, 'issueCreated', event); - - return { - requestId, - message: `🤖 Added issue creation event to processing queue (${requestId})`, - emoji: '🤖' - }; - }, - - /** - * Handle comment creation event - */ - commentCreated: async (c, event: LinearWebhookEvent) => { - console.log(`[ACTOR] Received comment creation event: ${event.data.id} on issue ${event.data.issue?.id}`); - updateDebugState(c, "Received comment creation event", `on issue ${event.data.issue?.id}`, event.data.id); - - // Add to queue - const requestId = enqueueRequest(c, 'commentCreated', event); - - return { - requestId, - message: `🤖 Added comment creation event to processing queue (${requestId})`, - emoji: '🤖' - }; - }, - - /** - * Handle issue update event - */ - issueUpdated: async (c, event: LinearWebhookEvent) => { - console.log(`[ACTOR] Received issue update event: ${event.data.id} - New state: ${event.data.state?.name}`); - updateDebugState(c, "Received issue update event", `New state: ${event.data.state?.name}`, event.data.id); - - // Add to queue - const requestId = enqueueRequest(c, 'issueUpdated', event); - - return { - requestId, - message: `🤖 Added issue update event to processing queue (${requestId})`, - emoji: '🤖' - }; - }, - - /** - * Get LLM conversation history - */ - getHistory: (c) => { - console.log(`[ACTOR] Getting conversation history`); - updateDebugState(c, "Getting history", "retrieving LLM conversation history"); - return c.state.llm.history; - }, - - /** - * Get debug information - */ - getDebug: (c) => { - console.log(`[ACTOR] Getting debug information`); - return c.state.debug; - }, - }, -}); - diff --git a/examples/linear-coding-agent/src/workers/coding-agent/types.ts b/examples/linear-coding-agent/src/workers/coding-agent/types.ts deleted file mode 100644 index e12bf1e72..000000000 --- a/examples/linear-coding-agent/src/workers/coding-agent/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Types used by the coding agent - */ - -// Linear Types -export type IssueStatus = 'In Progress' | 'In Review' | 'Done' | 'Canceled' | string; - -// GitHub Types -export interface GitHubFile { - path: string; - type: 'file' | 'directory'; - sha?: string; -} - -export interface GitHubFileContent { - content: string; - sha: string; - path: string; -} - -export interface PullRequestInfo { - id: number; - number: number; - url: string; - noDiff?: boolean; -} - -// LLM Types are now imported from the AI SDK in llm.ts - -export interface LLMToolResult { - success: boolean; - result: any; - error?: string; -} - -// Request types for queue -export type RequestType = 'issueCreated' | 'commentCreated' | 'issueUpdated'; - -export interface QueuedRequest { - id: string; // Unique ID for the request - type: RequestType; - timestamp: number; - data: LinearWebhookEvent; // The LinearWebhookEvent for the request - status: 'pending' | 'processing' | 'completed' | 'failed'; - error?: string; -} - -// Actor Types -export interface CodingAgentState { - // Linear issue information - linear: { - issueId: string; - status: IssueStatus; - llmProgressCommentId?: string | null; // ID of the comment used for tracking LLM progress - }; - - // GitHub repository information - github: { - owner: string; - repo: string; - baseBranch: string; - branchName: string; - prInfo: PullRequestInfo | null; - }; - - // Source code state - code: { - fileTree: GitHubFile[]; - modifiedFiles: Record; // path -> contents - }; - - // LLM conversation history - llm: { - history: LLMMessage[]; // Type is defined in llm.ts as CoreSystemMessage | CoreUserMessage | CoreAssistantMessage | CoreToolMessage - }; - - // Request queue - queue: { - requests: QueuedRequest[]; - isProcessing: boolean; - lastProcessed: number; // Timestamp of the last processed request - }; - - // Debug information - debug: { - currentOperation: string; // What the agent is currently working on - lastUpdated: number; // Timestamp of the last update - stage: string; // Current processing stage (e.g., "fetching files", "generating code", etc.) - requestId?: string; // Current request being processed - }; -} - -export interface CodingAgentVars { - // Store the current abort controller for LLM requests - llmAbortController?: AbortController; - - // Queue processing promise - queueProcessingPromise?: Promise; -} - -// Re-export LinearWebhookEvent for convenience -import { LinearWebhookEvent } from '../../types'; -import { LLMMessage } from './llm'; -export type { LinearWebhookEvent }; diff --git a/examples/linear-coding-agent/src/workers/registry.ts b/examples/linear-coding-agent/src/workers/registry.ts deleted file mode 100644 index 69b77e1df..000000000 --- a/examples/linear-coding-agent/src/workers/registry.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { setup } from "rivetkit"; -import dotenv from "dotenv"; -import { codingAgent } from "./coding-agent/mod"; - -// Load environment variables from .env file -dotenv.config(); - -// Create and export the app -export const registry = setup({ - workers: { codingAgent }, -}); - -// Export type for client type checking -export type Registry = typeof registry; diff --git a/examples/linear-coding-agent/tsconfig.json b/examples/linear-coding-agent/tsconfig.json deleted file mode 100644 index 474d2882c..000000000 --- a/examples/linear-coding-agent/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "target": "esnext", - /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "lib": ["esnext"], - /* Specify what JSX code is generated. */ - "jsx": "react-jsx", - - /* Specify what module code is generated. */ - "module": "esnext", - /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "bundler", - /* Specify type package names to be included without being referenced in a source file. */ - "types": ["node"], - /* Enable importing .json files */ - "resolveJsonModule": true, - - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ - "checkJs": false, - - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ - "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ - "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ - "forceConsistentCasingInFileNames": true, - - /* Enable all strict type-checking options. */ - "strict": true, - - /* Skip type checking all .d.ts files. */ - "skipLibCheck": true - }, - "include": ["src/**/*", "actors/**/*", "tests/**/*"] -} diff --git a/examples/resend-streaks/.gitignore b/examples/resend-streaks/.gitignore deleted file mode 100644 index 79b7a1192..000000000 --- a/examples/resend-streaks/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.actorcore -node_modules \ No newline at end of file diff --git a/examples/resend-streaks/README.md b/examples/resend-streaks/README.md deleted file mode 100644 index 38b20c8b6..000000000 --- a/examples/resend-streaks/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Resend Streaks for RivetKit - -Example project demonstrating email automation and streak tracking using Resend integration with [RivetKit](https://rivetkit.org). - -[Learn More →](https://github.com/rivet-gg/rivetkit) - -[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) - -## Getting Started - -### Prerequisites - -- Node.js -- Resend API key - -### Installation - -```sh -git clone https://github.com/rivet-gg/rivetkit -cd rivetkit/examples/resend-streaks -npm install -``` - -### Development - -```sh -npm run dev -``` - -Configure your Resend API key in the environment to enable email functionality. - -## License - -Apache 2.0 \ No newline at end of file diff --git a/examples/resend-streaks/package.json b/examples/resend-streaks/package.json deleted file mode 100644 index a935d83aa..000000000 --- a/examples/resend-streaks/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "resend-streaks", - "version": "0.9.0-rc.1", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx src/server.ts", - "check-types": "tsc --noEmit", - "test": "vitest run" - }, - "devDependencies": { - "@types/node": "^22.13.9", - "rivetkit": "workspace:*", - "tsx": "^3.12.7", - "typescript": "^5.7.3", - "vitest": "^3.1.1" - }, - "dependencies": { - "@date-fns/tz": "^1.2.0", - "@rivetkit/nodejs": "workspace:*", - "date-fns": "^4.1.0", - "resend": "^2.0.0" - }, - "example": { - "platforms": [ - "*" - ] - }, - "stableVersion": "0.0.0" -} diff --git a/examples/resend-streaks/src/server.ts b/examples/resend-streaks/src/server.ts deleted file mode 100644 index 4bf6ba53b..000000000 --- a/examples/resend-streaks/src/server.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { serve } from "@rivetkit/nodejs"; -import { registry } from "./workers/registry"; - -serve(registry); diff --git a/examples/resend-streaks/src/workers/registry.ts b/examples/resend-streaks/src/workers/registry.ts deleted file mode 100644 index 0f580eee4..000000000 --- a/examples/resend-streaks/src/workers/registry.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { TZDate } from "@date-fns/tz"; -import { UserError, worker, setup } from "rivetkit"; -import { addDays, set } from "date-fns"; -import { Resend } from "resend"; - -const user = worker({ - state: { - email: null as string | null, - timeZone: "UTC", - streakCount: 0, - lastCompletedAt: 0, - }, - - createVars: () => ({ - resend: new Resend(process.env.RESEND_API_KEY), - }), - - actions: { - completeSignUp: async (c, email: string, timeZone: string) => { - if (c.state.email) throw new UserError("Already signed up"); - - c.state.email = email; - c.state.timeZone = timeZone; - - // Schedule daily streak check - const tomorrow = set(addDays(TZDate.tz(timeZone), 1), { - hours: 17, - minutes: 0, - seconds: 0, - milliseconds: 0, - }); - await c.schedule.at(tomorrow.getTime(), "dailyStreakReminder"); - return { success: true }; - }, - - completeDailyChallenge: async (c) => { - if (!c.state.email) throw new UserError("Not signed up"); - - const today = TZDate.tz(c.state.timeZone); - const yesterday = addDays(today, -1); - const lastCompletedDate = TZDate.tz( - c.state.timeZone, - c.state.lastCompletedAt, - ); - - // Check if already completed - if (isSameDay(today, lastCompletedDate)) { - throw new UserError("Already completed streak today"); - } - - // Update streak - const isConsecutiveDay = isSameDay(lastCompletedDate, yesterday); - c.state.streakCount = isConsecutiveDay ? c.state.streakCount + 1 : 1; - c.state.lastCompletedAt = Date.now(); - - // Send congratulatory email - await c.vars.resend.emails.send({ - from: "streaks@example.com", - to: c.state.email, - subject: `Congratulations on Your ${c.state.streakCount}-Day Streak!`, - html: `

Congratulations on completing your ${c.state.streakCount}-day streak!

`, - }); - - return { streakCount: c.state.streakCount }; - }, - - dailyStreakReminder: async (c) => { - if (!c.state.email) throw new UserError("Not signed up"); - if (!c.state.timeZone) throw new UserError("Time zone not set"); - - const today = TZDate.tz(c.state.timeZone); - const lastCompletedDate = TZDate.tz( - c.state.timeZone, - c.state.lastCompletedAt, - ); - - // Don't send reminder if already completed today - if (!isSameDay(lastCompletedDate, today)) { - await c.vars.resend.emails.send({ - from: "streaks@example.com", - to: c.state.email, - subject: "Don't Break Your Streak!", - html: `

Don't forget to complete today's challenge to maintain your ${c.state.streakCount}-day streak!

`, - }); - } - - // Schedule the next check for tomorrow at 5 PM - const tomorrow = set(addDays(today, 1), { - hours: 17, - minutes: 0, - seconds: 0, - milliseconds: 0, - }); - await c.schedule.at(tomorrow.getTime(), "dailyStreakReminder"); - }, - }, -}); - -function isSameDay(a: TZDate, b: TZDate) { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -export const registry = setup({ - workers: { user }, -}); - -export type Registry = typeof registry; diff --git a/examples/resend-streaks/tests/user.test.ts b/examples/resend-streaks/tests/user.test.ts deleted file mode 100644 index 4df35cc61..000000000 --- a/examples/resend-streaks/tests/user.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { test, expect, vi, beforeEach } from "vitest"; -import { setupTest } from "rivetkit/test"; -import { registry } from "../src/workers/registry"; - -// Create mock for send method -const mockSendEmail = vi.fn().mockResolvedValue({ success: true }); - -// Set up the spy once before all tests -beforeEach(() => { - process.env.RESEND_API_KEY = "test_mock_api_key_12345"; - - vi.mock("resend", () => { - return { - Resend: vi.fn().mockImplementation(() => { - return { - emails: { - send: mockSendEmail - } - }; - }) - }; - }); - - mockSendEmail.mockClear(); -}); - -test("streak tracking with time zone signups", async (t) => { - const { client } = await setupTest(t, registry); - const actor = client.user.getOrCreate().connect(); - - // Sign up with specific time zone - const signupResult = await actor.completeSignUp( - "user@example.com", - "America/New_York", - ); - expect(signupResult.success).toBe(true); - - // Complete the challenge - const challengeResult = await actor.completeDailyChallenge(); - expect(challengeResult.streakCount).toBe(1); - - // Verify streak 1 email was sent - expect(mockSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: "user@example.com", - subject: "Congratulations on Your 1-Day Streak!", - }), - ); - - // Try to complete it again within 24 hours (should throw an error) - try { - await actor.completeDailyChallenge(); - // If we don't throw, test should fail - expect(true).toBe(false); - } catch (error: any) { - expect(error.message).toContain("Already completed"); - } - - // Fast forward time to the next day - await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); - - // Complete challenge again, check streak is +1 - const nextDayResult = await actor.completeDailyChallenge(); - expect(nextDayResult.streakCount).toBe(2); - - // Verify streak 2 email was sent - expect(mockSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: "user@example.com", - subject: "Congratulations on Your 2-Day Streak!", - }), - ); - - // Fast forward time to the next day again - await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); - - // Complete challenge again, check streak is now 3 - const thirdDayResult = await actor.completeDailyChallenge(); - expect(thirdDayResult.streakCount).toBe(3); - - // Verify streak 3 email was sent - expect(mockSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: "user@example.com", - subject: "Congratulations on Your 3-Day Streak!", - }), - ); - - // Fast forward 2 days then check again to make sure streak breaks - await vi.advanceTimersByTimeAsync(2 * 24 * 60 * 60 * 1000); - - // Streak should reset to 1 after missing days - const afterBreakResult = await actor.completeDailyChallenge(); - expect(afterBreakResult.streakCount).toBe(1); - - // Verify streak reset email was sent - expect(mockSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: "user@example.com", - subject: "Congratulations on Your 1-Day Streak!", - }), - ); -}); diff --git a/examples/resend-streaks/tsconfig.json b/examples/resend-streaks/tsconfig.json deleted file mode 100644 index 474d2882c..000000000 --- a/examples/resend-streaks/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "target": "esnext", - /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "lib": ["esnext"], - /* Specify what JSX code is generated. */ - "jsx": "react-jsx", - - /* Specify what module code is generated. */ - "module": "esnext", - /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "bundler", - /* Specify type package names to be included without being referenced in a source file. */ - "types": ["node"], - /* Enable importing .json files */ - "resolveJsonModule": true, - - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ - "checkJs": false, - - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ - "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ - "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ - "forceConsistentCasingInFileNames": true, - - /* Enable all strict type-checking options. */ - "strict": true, - - /* Skip type checking all .d.ts files. */ - "skipLibCheck": true - }, - "include": ["src/**/*", "actors/**/*", "tests/**/*"] -}