Add roomote to the monorepo (#109)

This commit is contained in:
Chris Estreich 2025-06-20 09:41:51 -07:00 committed by GitHub
parent 1ab782244b
commit e8e7274393
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 6181 additions and 592 deletions

27
.docker/Dockerfile.api Normal file
View file

@ -0,0 +1,27 @@
# docker compose build base api
FROM roomote-base AS base
WORKDIR /roo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/config-eslint/package.json ./packages/config-eslint/
COPY packages/config-typescript/package.json ./packages/config-typescript/
COPY packages/types/package.json ./packages/types/
COPY packages/ipc/package.json ./packages/ipc/
COPY apps/roomote/package.json ./apps/roomote/
COPY scripts/bootstrap.mjs ./scripts/
RUN pnpm install
COPY apps/roomote ./apps/roomote/
COPY packages/config-eslint ./packages/config-eslint/
COPY packages/config-typescript ./packages/config-typescript/
COPY packages/types ./packages/types/
COPY packages/ipc ./packages/ipc/
WORKDIR /roo/apps/roomote
RUN pnpm build
ENV NODE_ENV=production
EXPOSE 3001
CMD ["pnpm", "start"]

31
.docker/Dockerfile.base Normal file
View file

@ -0,0 +1,31 @@
# docker compose build base
FROM node:20-slim AS base
# Install pnpm
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# Install common system packages
RUN apt update && \
apt install -y \
curl \
git \
vim \
jq \
netcat-openbsd \
apt-transport-https \
ca-certificates \
gnupg \
lsb-release \
wget \
gpg \
gh \
&& rm -rf /var/lib/apt/lists/*
# Install Docker cli
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt update && apt install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*

View file

@ -0,0 +1,25 @@
# docker compose build base controller
FROM roomote-base AS base
WORKDIR /roo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/config-eslint/package.json ./packages/config-eslint/
COPY packages/config-typescript/package.json ./packages/config-typescript/
COPY packages/types/package.json ./packages/types/
COPY packages/ipc/package.json ./packages/ipc/
COPY apps/roomote/package.json ./apps/roomote/
COPY scripts/bootstrap.mjs ./scripts/
RUN pnpm install
COPY apps/roomote ./apps/roomote/
COPY packages/config-eslint ./packages/config-eslint/
COPY packages/config-typescript ./packages/config-typescript/
COPY packages/types ./packages/types/
COPY packages/ipc ./packages/ipc/
WORKDIR /roo/apps/roomote
ENV NODE_ENV=production
CMD ["pnpm", "controller"]

View file

@ -0,0 +1,25 @@
# docker compose build base dashboard
FROM roomote-base AS base
WORKDIR /roo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/config-eslint/package.json ./packages/config-eslint/
COPY packages/config-typescript/package.json ./packages/config-typescript/
COPY packages/types/package.json ./packages/types/
COPY packages/ipc/package.json ./packages/ipc/
COPY apps/roomote/package.json ./apps/roomote/
COPY scripts/bootstrap.mjs ./scripts/
RUN pnpm install
COPY apps/roomote ./apps/roomote/
COPY packages/config-eslint ./packages/config-eslint/
COPY packages/config-typescript ./packages/config-typescript/
COPY packages/types ./packages/types/
COPY packages/ipc ./packages/ipc/
WORKDIR /roo/apps/roomote
EXPOSE 3002
CMD ["pnpm", "dashboard"]

62
.docker/Dockerfile.worker Normal file
View file

@ -0,0 +1,62 @@
# docker compose build worker
# Note: Requires $GH_TOKEN to be set as build argument.
FROM roomote-base AS base
# Install additional worker-specific packages
RUN apt update && \
apt install -y \
xvfb \
&& rm -rf /var/lib/apt/lists/*
# Install VS Code
RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > packages.microsoft.gpg \
&& install -D -o root -g root -m 644 packages.microsoft.gpg /etc/apt/keyrings/packages.microsoft.gpg \
&& echo "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" | tee /etc/apt/sources.list.d/vscode.list > /dev/null \
&& rm -f packages.microsoft.gpg \
&& apt update && apt install -y code \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /roo
# Install extensions
RUN mkdir -p /roo/.vscode \
&& code --no-sandbox --user-data-dir /roo/.vscode --install-extension dbaeumer.vscode-eslint \
&& code --no-sandbox --user-data-dir /roo/.vscode --install-extension esbenp.prettier-vscode \
&& code --no-sandbox --user-data-dir /roo/.vscode --install-extension csstools.postcss \
&& code --no-sandbox --user-data-dir /roo/.vscode --install-extension RooVeterinaryInc.roo-cline
# Clone repo (requires $GH_TOKEN)
ARG GH_TOKEN
ENV GH_TOKEN=${GH_TOKEN}
WORKDIR /roo/repos
RUN git config --global user.email "chris@roocode.com"
RUN git config --global user.name "Roo Code"
RUN git config --global credential.helper store
RUN echo "https://oauth2:${GH_TOKEN}@github.com" > ~/.git-credentials
RUN gh repo clone RooCodeInc/Roo-Code
WORKDIR /roo/repos/Roo-Code
RUN gh repo set-default RooCodeInc/Roo-Code
RUN pnpm install
# Install dependencies
WORKDIR /roo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/config-eslint/package.json ./packages/config-eslint/
COPY packages/config-typescript/package.json ./packages/config-typescript/
COPY packages/types/package.json ./packages/types/
COPY packages/ipc/package.json ./packages/ipc/
COPY apps/roomote/package.json ./apps/roomote/
COPY scripts/bootstrap.mjs ./scripts/
RUN pnpm install
COPY apps/roomote ./apps/roomote/
COPY packages/config-eslint ./packages/config-eslint/
COPY packages/config-typescript ./packages/config-typescript/
COPY packages/types ./packages/types/
COPY packages/ipc ./packages/ipc/
WORKDIR /roo/apps/roomote
ENV NODE_ENV=production
CMD ["pnpm", "worker"]

5
.gitignore vendored
View file

@ -9,6 +9,7 @@ node_modules
Thumbs.db
# .env
.env
.env*.local
.env*.production
@ -17,3 +18,7 @@ Thumbs.db
# vercel
.vercel
# docker
.docker/data
.docker/logs

View file

@ -1 +1,27 @@
# Roo Code Cloud Monorepo
### Web
To start the web app you first need to start the `postgres` and `clickhouse` docker services:
```sh
pnpm db:up
```
This will automatically sync your database to the latest version of the schema. If you need to reset your database at any point, you can run:
```sh
pnpm db:reset
```
Then you can start the app in dev mode:
```sh
pnpm --filter @roo-code-cloud/web dev
```
The app will be available at (localhost:3000)[http://localhost:3000/].
### Roomote
TBD

View file

@ -0,0 +1,9 @@
DATABASE_URL=postgresql://postgres:password@localhost:5433/cloud_agents
REDIS_URL=redis://localhost:6380
GH_WEBHOOK_SECRET=your-webhook-secret-here
GH_TOKEN=your-token-here
OPENROUTER_API_KEY=sk-or-v1-...
SLACK_API_TOKEN=xoxb-...

3
apps/roomote/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# next.js
/.next
/out

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,21 @@
CREATE TABLE "cloud_jobs" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "cloud_jobs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"type" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"payload" jsonb NOT NULL,
"result" jsonb,
"error" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"started_at" timestamp,
"completed_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "cloud_tasks" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "cloud_tasks_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"job_id" integer NOT NULL,
"task_id" integer,
"container_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "cloud_tasks" ADD CONSTRAINT "cloud_tasks_job_id_cloud_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."cloud_jobs"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1 @@
ALTER TABLE "cloud_jobs" ADD COLUMN "slack_thread_ts" text;

View file

@ -0,0 +1 @@
DROP TABLE "cloud_tasks" CASCADE;

View file

@ -0,0 +1,164 @@
{
"id": "8f65bfed-78de-4e22-a15f-36de8afe5f2e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.cloud_jobs": {
"name": "cloud_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "cloud_jobs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"result": {
"name": "result",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.cloud_tasks": {
"name": "cloud_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "cloud_tasks_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"job_id": {
"name": "job_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"container_id": {
"name": "container_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"cloud_tasks_job_id_cloud_jobs_id_fk": {
"name": "cloud_tasks_job_id_cloud_jobs_id_fk",
"tableFrom": "cloud_tasks",
"tableTo": "cloud_jobs",
"columnsFrom": ["job_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,170 @@
{
"id": "4cae0c18-141d-40a3-acc8-38fcd7f07534",
"prevId": "8f65bfed-78de-4e22-a15f-36de8afe5f2e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.cloud_jobs": {
"name": "cloud_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "cloud_jobs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"result": {
"name": "result",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"slack_thread_ts": {
"name": "slack_thread_ts",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.cloud_tasks": {
"name": "cloud_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "cloud_tasks_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"job_id": {
"name": "job_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"container_id": {
"name": "container_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"cloud_tasks_job_id_cloud_jobs_id_fk": {
"name": "cloud_tasks_job_id_cloud_jobs_id_fk",
"tableFrom": "cloud_tasks",
"tableTo": "cloud_jobs",
"columnsFrom": ["job_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,105 @@
{
"id": "16afebd7-b27a-457e-b3d5-4de50a597c2e",
"prevId": "4cae0c18-141d-40a3-acc8-38fcd7f07534",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.cloud_jobs": {
"name": "cloud_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "cloud_jobs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"result": {
"name": "result",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"slack_thread_ts": {
"name": "slack_thread_ts",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1749740498648,
"tag": "0000_cuddly_luke_cage",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1750099429125,
"tag": "0001_fluffy_sasquatch",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1750107620472,
"tag": "0002_brief_sentry",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,4 @@
import { nextJsConfig } from '@roo-code-cloud/config-eslint/next-js';
/** @type {import("eslint").Linter.Config} */
export default [...nextJsConfig];

5
apps/roomote/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
serverExternalPackages: ['postgres', 'ioredis', 'bullmq'],
};
export default nextConfig;

View file

@ -1,4 +1,55 @@
{
"name": "@roo-code-cloud/roomote",
"private": true
"private": true,
"type": "module",
"scripts": {
"lint": "next lint --max-warnings 0",
"check-types": "tsc --noEmit",
"test": "vitest",
"dev": "concurrently \"next dev --port 3001\" \"ngrok http 3001 --domain cte.ngrok.dev\"",
"build": "next build",
"start": "next start --port 3001",
"clean": "rimraf .next .turbo",
"drizzle-kit": "dotenvx run -f .env -- tsx node_modules/drizzle-kit/bin.cjs",
"db:generate": "pnpm drizzle-kit generate",
"db:migrate": "pnpm drizzle-kit migrate",
"db:push": "pnpm drizzle-kit push",
"db:check": "pnpm drizzle-kit check",
"db:studio": "pnpm drizzle-kit studio",
"services:start": "docker compose up -d db redis dashboard",
"services:stop": "docker compose down dashboard redis db",
"worker": "dotenvx run -f .env -- tsx src/lib/worker.ts",
"controller": "dotenvx run -f .env -- tsx src/lib/controller.ts",
"dashboard": "tsx scripts/dashboard.ts"
},
"dependencies": {
"@bull-board/api": "^6.10.1",
"@bull-board/express": "^6.10.1",
"@bull-board/ui": "^6.10.1",
"@roo-code/types": "^1.26.0",
"@roo-code-cloud/ipc": "workspace:^",
"bullmq": "^5.37.0",
"drizzle-orm": "^0.44.1",
"execa": "^9.6.0",
"express": "^5.1.0",
"ioredis": "^5.4.3",
"next": "^15.3.3",
"p-wait-for": "^5.0.2",
"postgres": "^3.4.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^3.25.41"
},
"devDependencies": {
"@roo-code-cloud/config-eslint": "workspace:^",
"@roo-code-cloud/config-typescript": "workspace:^",
"@types/express": "^5.0.3",
"@types/node": "^22.15.20",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"concurrently": "^9.1.0",
"drizzle-kit": "^0.31.1",
"tsx": "^4.19.3",
"vitest": "^3.2.3"
}
}

38
apps/roomote/scripts/build.sh Executable file
View file

@ -0,0 +1,38 @@
#!/bin/bash
# Build script for roomote services.
# This ensures the base image is built before dependent services.
set -e
build_service() {
local service=$1
case $service in
"dashboard"|"api"|"worker"|"controller")
echo "Building base image first..."
docker compose build base
echo "Building $service..."
docker compose build $service
;;
"base")
echo "Building base image..."
docker compose build base
;;
*)
echo "Building $service..."
docker compose build $service
;;
esac
}
if [ $# -eq 0 ]; then
echo "Usage: $0 <service_name>"
echo "Available services: base, dashboard, api, worker, controller, db, redis"
echo "Example: $0 dashboard"
exit 1
fi
build_service $1
echo "Build completed successfully!"

View file

@ -0,0 +1,24 @@
import IORedis from 'ioredis';
import { Queue } from 'bullmq';
import { ExpressAdapter } from '@bull-board/express';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { createBullBoard } from '@bull-board/api';
import express from 'express';
import type { Express, Request, Response } from 'express';
const redis = new IORedis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
const queue = new Queue('roomote', { connection: redis });
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({ queues: [new BullMQAdapter(queue)], serverAdapter });
const port = 3002;
const app: Express = express();
app.use('/admin/queues', serverAdapter.getRouter());
app.use('/', (req: Request, res: Response) => res.redirect('/admin/queues'));
app.listen(port, () =>
console.log(`Bull Board running on: http://localhost:${port}/admin/queues`),
);

View file

@ -0,0 +1,74 @@
#!/bin/bash
REPO="RooCodeInc/Roo-Code"
ISSUE_NUMBER="$1"
BASE_URL="http://localhost:3001"
JOBS_ENDPOINT="$BASE_URL/api/jobs"
if [ -z "$ISSUE_NUMBER" ]; then
echo "Usage: $0 <issue_number> [repo]"
echo ""
echo "Examples:"
echo " $0 4567 # Fetch issue #4567 from RooCodeInc/Roo-Code"
echo " $0 123 owner/repo # Fetch issue #123 from owner/repo"
echo ""
echo "This script fetches real GitHub issue data and enqueues it as a job."
exit 1
fi
if [ -n "$2" ]; then
REPO="$2"
fi
if ! command -v gh &> /dev/null; then
echo "Error: GitHub CLI (gh) is not installed. Please install it first."
echo "Visit: https://cli.github.com/"
exit 1
fi
if ! gh auth status &> /dev/null; then
echo "Error: Not authenticated with GitHub CLI. Please run 'gh auth login' first."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed. Please install it first."
echo "Visit: https://jqlang.github.io/jq/download/"
exit 1
fi
echo "Fetching issue #${ISSUE_NUMBER} from ${REPO}..."
ISSUE_DATA=$(gh issue view ${ISSUE_NUMBER} --repo ${REPO} --json title,body,labels 2>/dev/null)
if [ $? -ne 0 ]; then
echo "Error: Failed to fetch issue #${ISSUE_NUMBER} from ${REPO}"
echo "Please check that the issue exists and you have access to the repository."
exit 1
fi
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
BODY=$(echo "$ISSUE_DATA" | jq -r '.body // ""')
LABELS=$(echo "$ISSUE_DATA" | jq -r '[.labels[].name] | @json')
JSON_PAYLOAD=$(jq -n \
--arg type "github.issue.fix" \
--arg repo "$REPO" \
--argjson issue "$ISSUE_NUMBER" \
--arg title "$TITLE" \
--arg body "$BODY" \
--argjson labels "$LABELS" \
'{
type: $type,
payload: {
repo: $repo,
issue: $issue,
title: $title,
body: $body,
labels: $labels
}
}')
echo "curl -X POST \"$JOBS_ENDPOINT\" -H \"Content-Type: application/json\" -d \"$JSON_PAYLOAD\" -w \"\nStatus: %{http_code}\n\n\""
curl -X POST "$JOBS_ENDPOINT" -H "Content-Type: application/json" -d "$JSON_PAYLOAD" -w "\nStatus: %{http_code}\n\n"

View file

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { redis } from '@/lib';
export async function GET() {
try {
const services = { database: false, redis: false };
try {
await db.execute('SELECT 1');
services.database = true;
} catch (error) {
console.error('Database health check failed:', error);
}
try {
await redis.ping();
services.redis = true;
} catch (error) {
console.error('Redis health check failed:', error);
}
const allHealthy = Object.values(services).every(Boolean);
return NextResponse.json(
{ status: allHealthy ? 'ok' : 'error', services },
{ status: allHealthy ? 200 : 500 },
);
} catch (error) {
console.error('Health check error:', error);
return NextResponse.json(
{ status: 'error', error: 'Internal server error' },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, cloudJobs } from '@/db';
type Params = Promise<{ id: string }>;
export async function GET(
request: NextRequest,
{ params }: { params: Params },
) {
try {
const { id } = await params;
const jobId = parseInt(id, 10);
if (isNaN(jobId)) {
return NextResponse.json({ error: 'Invalid job ID' }, { status: 400 });
}
const [job] = await db
.select()
.from(cloudJobs)
.where(eq(cloudJobs.id, jobId))
.limit(1);
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
return NextResponse.json({
id: job.id,
type: job.type,
status: job.status,
payload: job.payload,
result: job.result,
error: job.error,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
});
} catch (error) {
console.error('Error fetching job:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createJobSchema } from '@/types';
import { db, cloudJobs } from '@/db';
import { enqueue } from '@/lib';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const values = createJobSchema.parse(body);
const [job] = await db
.insert(cloudJobs)
.values({ ...values, status: 'pending' })
.returning();
if (!job) {
throw new Error('Failed to create `cloudJobs` record.');
}
const enqueuedJob = await enqueue({ jobId: job.id, ...values });
return NextResponse.json({
message: 'job_enqueued',
jobId: job.id,
enqueuedJobId: enqueuedJob.id,
});
} catch (error) {
console.error('Create Job Error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'bad_request', details: error.errors },
{ status: 400 },
);
}
return NextResponse.json(
{ error: 'internal_server_error' },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,376 @@
// npx vitest src/app/api/webhooks/github/__tests__/route.test.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const mockVerifySignature = vi.fn();
const mockHandleIssueEvent = vi.fn();
const mockHandlePullRequestEvent = vi.fn();
const mockHandleIssueCommentEvent = vi.fn();
const mockHandlePullRequestReviewCommentEvent = vi.fn();
vi.mock('../handlers', () => ({
verifySignature: mockVerifySignature,
handleIssueEvent: mockHandleIssueEvent,
handlePullRequestEvent: mockHandlePullRequestEvent,
handleIssueCommentEvent: mockHandleIssueCommentEvent,
handlePullRequestReviewCommentEvent: mockHandlePullRequestReviewCommentEvent,
}));
vi.mock('next/server', async () => {
const actual = await vi.importActual('next/server');
return {
...actual,
NextResponse: {
json: vi.fn().mockReturnValue({ mocked: true }),
},
};
});
describe('GitHub Webhook Route', () => {
let POST: typeof import('../route').POST;
const validSignature = 'sha256=test-signature';
const validBody = JSON.stringify({ action: 'opened', number: 123 });
const webhookSecret = 'test-secret';
beforeEach(async () => {
vi.clearAllMocks();
process.env.GH_WEBHOOK_SECRET = webhookSecret;
// Import the POST function after mocks are set up.
const routeModule = await import('../route');
POST = routeModule.POST;
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.GH_WEBHOOK_SECRET;
});
const createMockRequest = (
headers: Record<string, string>,
body: string = validBody,
) => {
return {
headers: {
get: vi.fn((name: string) => headers[name] || null),
},
text: vi.fn().mockResolvedValue(body),
} as unknown as NextRequest;
};
describe('signature validation', () => {
it('should return 400 when signature header is missing', async () => {
const request = createMockRequest({});
await POST(request);
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: 'missing_signature' },
{ status: 400 },
);
});
it('should return 401 when signature is invalid', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
});
mockVerifySignature.mockReturnValue(false);
await POST(request);
expect(mockVerifySignature).toHaveBeenCalledWith(
validBody,
validSignature,
webhookSecret,
);
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: 'invalid_signature' },
{ status: 401 },
);
});
it('should proceed when signature is valid', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'issues',
});
mockVerifySignature.mockReturnValue(true);
mockHandleIssueEvent.mockResolvedValue({ status: 200 });
await POST(request);
expect(mockVerifySignature).toHaveBeenCalledWith(
validBody,
validSignature,
webhookSecret,
);
expect(mockHandleIssueEvent).toHaveBeenCalledWith(validBody);
});
});
describe('event handling', () => {
beforeEach(() => {
mockVerifySignature.mockReturnValue(true);
});
it('should handle issues event', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'issues',
});
const expectedResponse = { status: 200, data: 'success' };
mockHandleIssueEvent.mockResolvedValue(expectedResponse);
const result = await POST(request);
expect(mockHandleIssueEvent).toHaveBeenCalledWith(validBody);
expect(result).toBe(expectedResponse);
});
it('should handle pull_request event', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'pull_request',
});
const expectedResponse = { status: 200, data: 'success' };
mockHandlePullRequestEvent.mockResolvedValue(expectedResponse);
const result = await POST(request);
expect(mockHandlePullRequestEvent).toHaveBeenCalledWith(validBody);
expect(result).toBe(expectedResponse);
});
it('should handle issue_comment event', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'issue_comment',
});
const expectedResponse = { status: 200, data: 'success' };
mockHandleIssueCommentEvent.mockResolvedValue(expectedResponse);
const result = await POST(request);
expect(mockHandleIssueCommentEvent).toHaveBeenCalledWith(validBody);
expect(result).toBe(expectedResponse);
});
it('should handle pull_request_review_comment event', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'pull_request_review_comment',
});
const expectedResponse = { status: 200, data: 'success' };
mockHandlePullRequestReviewCommentEvent.mockResolvedValue(
expectedResponse,
);
const result = await POST(request);
expect(mockHandlePullRequestReviewCommentEvent).toHaveBeenCalledWith(
validBody,
);
expect(result).toBe(expectedResponse);
});
it('should ignore unknown events', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'unknown_event',
});
await POST(request);
expect(NextResponse.json).toHaveBeenCalledWith({
message: 'event_ignored',
});
expect(mockHandleIssueEvent).not.toHaveBeenCalled();
expect(mockHandlePullRequestEvent).not.toHaveBeenCalled();
expect(mockHandleIssueCommentEvent).not.toHaveBeenCalled();
expect(mockHandlePullRequestReviewCommentEvent).not.toHaveBeenCalled();
});
it('should handle missing event header', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
});
await POST(request);
expect(NextResponse.json).toHaveBeenCalledWith({
message: 'event_ignored',
});
});
});
describe('error handling', () => {
beforeEach(() => {
mockVerifySignature.mockReturnValue(true);
});
it('should handle ZodError with 400 status', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'issues',
});
const zodError = new z.ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'number',
path: ['test'],
message: 'Expected string, received number',
},
]);
mockHandleIssueEvent.mockRejectedValue(zodError);
await POST(request);
expect(NextResponse.json).toHaveBeenCalledWith(
{
error: 'bad_request',
details: zodError.errors,
},
{ status: 400 },
);
});
it('should handle generic errors with 500 status', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
'x-github-event': 'issues',
});
const genericError = new Error('Database connection failed');
mockHandleIssueEvent.mockRejectedValue(genericError);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await POST(request);
expect(consoleSpy).toHaveBeenCalledWith(
'GitHub Webhook Error:',
genericError,
);
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: 'internal_server_error' },
{ status: 500 },
);
consoleSpy.mockRestore();
});
it('should handle errors during signature verification', async () => {
const request = createMockRequest({
'x-hub-signature-256': validSignature,
});
mockVerifySignature.mockImplementation(() => {
throw new Error('Signature verification failed');
});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await POST(request);
expect(consoleSpy).toHaveBeenCalledWith(
'GitHub Webhook Error:',
expect.any(Error),
);
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: 'internal_server_error' },
{ status: 500 },
);
consoleSpy.mockRestore();
});
it('should handle errors when reading request body', async () => {
const request = {
headers: {
get: vi.fn().mockReturnValue(validSignature),
},
text: vi.fn().mockRejectedValue(new Error('Failed to read body')),
} as unknown as NextRequest;
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await POST(request);
expect(consoleSpy).toHaveBeenCalledWith(
'GitHub Webhook Error:',
expect.any(Error),
);
expect(NextResponse.json).toHaveBeenCalledWith(
{ error: 'internal_server_error' },
{ status: 500 },
);
consoleSpy.mockRestore();
});
});
describe('integration scenarios', () => {
beforeEach(() => {
mockVerifySignature.mockReturnValue(true);
});
it('should process a complete valid webhook request', async () => {
const issuePayload = {
action: 'opened',
issue: {
number: 123,
title: 'Test issue',
body: 'This is a test issue',
},
};
const request = createMockRequest(
{
'x-hub-signature-256': validSignature,
'x-github-event': 'issues',
},
JSON.stringify(issuePayload),
);
const expectedResponse = { status: 200, jobId: 'job-123' };
mockHandleIssueEvent.mockResolvedValue(expectedResponse);
const result = await POST(request);
expect(request.text).toHaveBeenCalled();
expect(mockVerifySignature).toHaveBeenCalledWith(
JSON.stringify(issuePayload),
validSignature,
webhookSecret,
);
expect(mockHandleIssueEvent).toHaveBeenCalledWith(
JSON.stringify(issuePayload),
);
expect(result).toBe(expectedResponse);
});
it('should handle empty request body', async () => {
const request = createMockRequest(
{
'x-hub-signature-256': validSignature,
'x-github-event': 'issues',
},
'',
);
mockHandleIssueEvent.mockResolvedValue({ status: 200 });
await POST(request);
expect(mockVerifySignature).toHaveBeenCalledWith(
'',
validSignature,
webhookSecret,
);
expect(mockHandleIssueEvent).toHaveBeenCalledWith('');
});
});
});

View file

@ -0,0 +1,294 @@
// npx vitest src/app/api/webhooks/github/handlers/__tests__/utils.test.ts
import { createHmac } from 'crypto';
vi.mock('@/db', () => ({
db: {
insert: vi.fn(),
},
cloudJobs: {},
}));
vi.mock('@/lib', () => ({
enqueue: vi.fn(),
}));
describe('GitHub Webhook Utils', () => {
let verifySignature: typeof import('../utils').verifySignature;
let createAndEnqueueJob: typeof import('../utils').createAndEnqueueJob;
let mockDb: { insert: ReturnType<typeof vi.fn> };
let mockEnqueue: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
const dbModule = await import('@/db');
const libModule = await import('@/lib');
mockDb = dbModule.db as unknown as { insert: ReturnType<typeof vi.fn> };
mockEnqueue = libModule.enqueue as unknown as ReturnType<typeof vi.fn>;
const utilsModule = await import('../utils');
verifySignature = utilsModule.verifySignature;
createAndEnqueueJob = utilsModule.createAndEnqueueJob;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('verifySignature', () => {
const secret = 'test-secret';
it('should return true for valid signature', () => {
const body = 'test body';
// Calculate the actual HMAC for this body and secret.
const actualHash = createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
const validSignature = `sha256=${actualHash}`;
const result = verifySignature(body, validSignature, secret);
expect(result).toBe(true);
});
it('should return false for invalid signature', () => {
const body = 'test body';
const invalidSignature = 'sha256=invalid';
const result = verifySignature(body, invalidSignature, secret);
expect(result).toBe(false);
});
it('should handle signature without sha256= prefix', () => {
const body = 'test body';
const signature = '1234567890abcdef';
const result = verifySignature(body, signature, secret);
// This will be false since we're not mocking crypto properly for this test.
expect(typeof result).toBe('boolean');
});
it('should work with empty body', () => {
const body = '';
const signature = 'sha256=somehash';
const result = verifySignature(body, signature, secret);
expect(typeof result).toBe('boolean');
});
it('should work with special characters in body', () => {
const body = '{"test": "value with 特殊字符 and émojis 🎉"}';
const signature = 'sha256=somehash';
const result = verifySignature(body, signature, secret);
expect(typeof result).toBe('boolean');
});
});
describe('createAndEnqueueJob', () => {
const mockJob = { id: 123 };
const mockEnqueuedJob = { id: 'enqueued-123' };
const mockCloudJobs = {};
beforeEach(() => {
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([mockJob]),
}),
});
mockEnqueue.mockResolvedValue(mockEnqueuedJob);
});
it('should create and enqueue a job successfully', async () => {
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test issue',
body: 'Test body',
};
const result = await createAndEnqueueJob(type, payload);
expect(mockDb.insert).toHaveBeenCalledWith(mockCloudJobs);
expect(mockEnqueue).toHaveBeenCalledWith({
jobId: mockJob.id,
type,
payload,
});
expect(result).toEqual({
jobId: mockJob.id,
enqueuedJobId: mockEnqueuedJob.id,
});
});
it('should throw error when database insert fails', async () => {
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
}),
});
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test',
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Failed to create `cloudJobs` record.',
);
});
it('should throw error when enqueue fails to return job ID', async () => {
mockEnqueue.mockResolvedValue({ id: null });
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test',
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Failed to get enqueued job ID.',
);
});
it('should throw error when enqueue returns undefined', async () => {
mockEnqueue.mockResolvedValue({});
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test',
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Failed to get enqueued job ID.',
);
});
it('should handle different job types', async () => {
const type = 'github.pr.comment.respond';
const payload = {
repo: 'test/repo',
prNumber: 456,
prTitle: 'Test PR',
prBody: 'Test PR body',
prBranch: 'feature/test',
baseRef: 'main',
commentId: 789,
commentBody: 'Test comment',
commentAuthor: 'testuser',
commentType: 'issue_comment' as const,
commentUrl: 'https://github.com/test/repo/issues/456#issuecomment-789',
};
const result = await createAndEnqueueJob(type, payload);
expect(mockEnqueue).toHaveBeenCalledWith({
jobId: mockJob.id,
type,
payload,
});
expect(result).toEqual({
jobId: mockJob.id,
enqueuedJobId: mockEnqueuedJob.id,
});
});
it('should log the enqueued job', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test',
body: 'Test',
};
await createAndEnqueueJob(type, payload);
expect(consoleSpy).toHaveBeenCalledWith(
`🔗 Enqueued ${type} job (id: ${mockJob.id}) ->`,
payload,
);
consoleSpy.mockRestore();
});
it('should handle database connection errors', async () => {
mockDb.insert.mockImplementation(() => {
throw new Error('Database connection failed');
});
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test',
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Database connection failed',
);
});
it('should handle enqueue service errors', async () => {
mockEnqueue.mockRejectedValue(new Error('Queue service unavailable'));
const type = 'github.issue.fix';
const payload = {
repo: 'test/repo',
issue: 123,
title: 'Test',
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Queue service unavailable',
);
});
});
describe('integration tests', () => {
it('should work together in a realistic scenario', async () => {
// Test job creation in a realistic webhook scenario.
const mockJob = { id: 456 };
const mockEnqueuedJob = { id: 'job-456' };
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([mockJob]),
}),
});
mockEnqueue.mockResolvedValue(mockEnqueuedJob);
// Create job after successful verification.
const result = await createAndEnqueueJob('github.issue.fix', {
repo: 'test/repo',
issue: 123,
title: 'Test issue',
body: 'Test issue body',
});
expect(result).toEqual({
jobId: mockJob.id,
enqueuedJobId: mockEnqueuedJob.id,
});
});
});
});

View file

@ -0,0 +1,5 @@
export { verifySignature } from './utils';
export { handleIssueEvent } from './issueHandler';
export { handlePullRequestEvent } from './pullRequestHandler';
export { handleIssueCommentEvent } from './issueCommentHandler';
export { handlePullRequestReviewCommentEvent } from './pullRequestReviewCommentHandler';

View file

@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import type { JobPayload } from '@/types';
import { createAndEnqueueJob } from './utils';
const githubIssueCommentWebhookSchema = z.object({
action: z.string(),
issue: z.object({
number: z.number(),
title: z.string(),
body: z.string().nullable(),
pull_request: z.object({ url: z.string() }).optional(),
}),
comment: z.object({
id: z.number(),
body: z.string(),
html_url: z.string(),
user: z.object({ login: z.string() }),
}),
repository: z.object({
full_name: z.string(),
}),
});
const githubPullRequestWebhookSchema = z.object({
number: z.number(),
title: z.string(),
body: z.string().nullable(),
head: z.object({ ref: z.string() }),
base: z.object({ ref: z.string() }),
});
export async function handleIssueCommentEvent(body: string) {
const data = githubIssueCommentWebhookSchema.parse(JSON.parse(body));
const { action, comment, issue, repository } = data;
if (action !== 'created') {
return NextResponse.json({ message: 'action_ignored' });
}
if (!comment.body.includes('@roomote')) {
return NextResponse.json({ message: 'no_roomote_mention' });
}
console.log('🗄️ Issue Comment Webhook ->', data);
// Handle PR comments (when comment is on a pull request)
if (issue.pull_request) {
const response = await fetch(issue.pull_request.url);
if (!response.ok) {
return NextResponse.json({ message: 'failed_to_fetch_pull_request' });
}
// Example:
// https://api.github.com/repos/RooCodeInc/Roo-Code/pulls/4796
const pull_request = githubPullRequestWebhookSchema.parse(
await response.json(),
);
console.log(`🗄️ Pull Request -> ${issue.pull_request.url}`, pull_request);
const payload: JobPayload<'github.pr.comment.respond'> = {
repo: repository.full_name,
prNumber: pull_request.number,
prTitle: pull_request.title,
prBody: pull_request.body || '',
prBranch: pull_request.head.ref,
baseRef: pull_request.base.ref,
commentId: comment.id,
commentBody: comment.body,
commentAuthor: comment.user.login,
commentType: 'issue_comment',
commentUrl: comment.html_url,
};
const { jobId, enqueuedJobId } = await createAndEnqueueJob(
'github.pr.comment.respond',
payload,
);
return NextResponse.json({
message: 'pr_comment_job_enqueued',
jobId,
enqueuedJobId,
});
}
// Handle issue comments (when comment is on a regular issue)
const type = 'github.issue.comment.respond' as const;
const payload: JobPayload<typeof type> = {
repo: repository.full_name,
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
commentId: comment.id,
commentBody: comment.body,
commentAuthor: comment.user.login,
commentUrl: comment.html_url,
};
const { jobId, enqueuedJobId } = await createAndEnqueueJob(type, payload);
return NextResponse.json({
message: 'issue_comment_job_enqueued',
jobId,
enqueuedJobId,
});
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import type { JobPayload } from '@/types';
import { createAndEnqueueJob } from './utils';
const githubIssueWebhookSchema = z.object({
action: z.string(),
issue: z.object({
number: z.number(),
title: z.string(),
body: z.string().nullable(),
labels: z.array(z.object({ name: z.string() })),
}),
repository: z.object({
full_name: z.string(),
}),
});
export async function handleIssueEvent(body: string) {
const data = githubIssueWebhookSchema.parse(JSON.parse(body));
const { action, repository, issue } = data;
if (action !== 'opened') {
return NextResponse.json({ message: 'action_ignored' });
}
console.log('🗄️ Issue Webhook ->', data);
const payload: JobPayload<'github.issue.fix'> = {
repo: repository.full_name,
issue: issue.number,
title: issue.title,
body: issue.body || '',
labels: issue.labels.map(({ name }) => name),
};
const { jobId, enqueuedJobId } = await createAndEnqueueJob(
'github.issue.fix',
payload,
);
return NextResponse.json({ message: 'job_enqueued', jobId, enqueuedJobId });
}

View file

@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, cloudJobs } from '@/db';
import { SlackNotifier } from '@/lib/slack';
const githubPullRequestWebhookSchema = z.object({
action: z.string(),
pull_request: z.object({
number: z.number(),
title: z.string(),
body: z.string().nullable(),
html_url: z.string(),
}),
repository: z.object({
full_name: z.string(),
}),
});
export async function handlePullRequestEvent(body: string) {
const data = githubPullRequestWebhookSchema.parse(JSON.parse(body));
const { action, pull_request, repository } = data;
if (action !== 'opened') {
return NextResponse.json({ message: 'action_ignored' });
}
console.log('🗄️ PR Webhook ->', data);
// Extract issue number from PR title or body (looking for "Fixes #123" pattern).
const issueNumberMatch =
pull_request.title.match(/(?:fixes|closes|resolves)\s+#(\d+)/i) ||
(pull_request.body &&
pull_request.body.match(/(?:fixes|closes|resolves)\s+#(\d+)/i));
if (!issueNumberMatch) {
return NextResponse.json({ message: 'no_issue_reference_found' });
}
const issueNumber = parseInt(issueNumberMatch[1]!, 10);
// Find the job that corresponds to this issue.
const jobs = await db
.select()
.from(cloudJobs)
.where(eq(cloudJobs.type, 'github.issue.fix'));
// Filter jobs to find the one matching this repo and issue.
const job = jobs.find((j) => {
const payload = j.payload as { repo: string; issue: number };
return (
payload.repo === repository.full_name && payload.issue === issueNumber
);
});
if (!job || !job.slackThreadTs) {
console.log('No job found or no slack thread for issue', issueNumber);
return NextResponse.json({ message: 'no_job_or_slack_thread_found' });
}
const notifier = new SlackNotifier();
await notifier.postTaskUpdated(
job.slackThreadTs,
`🎉 Pull request created: <${pull_request.html_url}|PR #${pull_request.number}>\n*${pull_request.title}*`,
'success',
);
return NextResponse.json({ message: 'slack_notification_sent' });
}

View file

@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import type { JobPayload } from '@/types';
import { createAndEnqueueJob } from './utils';
export const githubPullRequestReviewCommentWebhookSchema = z.object({
action: z.string(),
comment: z.object({
id: z.number(),
body: z.string(),
html_url: z.string(),
user: z.object({
login: z.string(),
}),
}),
pull_request: z.object({
number: z.number(),
title: z.string(),
body: z.string().nullable(),
head: z.object({
ref: z.string(),
}),
base: z.object({
ref: z.string(),
}),
}),
repository: z.object({
full_name: z.string(),
}),
});
export async function handlePullRequestReviewCommentEvent(body: string) {
const data = githubPullRequestReviewCommentWebhookSchema.parse(
JSON.parse(body),
);
const { action, comment, pull_request, repository } = data;
if (action !== 'created') {
return NextResponse.json({ message: 'action_ignored' });
}
if (!comment.body.includes('@roomote')) {
return NextResponse.json({ message: 'no_roomote_mention' });
}
console.log('🗄️ PR Review Comment Webhook ->', data);
const payload: JobPayload<'github.pr.comment.respond'> = {
repo: repository.full_name,
prNumber: pull_request.number,
prTitle: pull_request.title,
prBody: pull_request.body || '',
prBranch: pull_request.head.ref,
baseRef: pull_request.base.ref,
commentId: comment.id,
commentBody: comment.body,
commentAuthor: comment.user.login,
commentType: 'review_comment',
commentUrl: comment.html_url,
};
const { jobId, enqueuedJobId } = await createAndEnqueueJob(
'github.pr.comment.respond',
payload,
);
return NextResponse.json({ message: 'job_enqueued', jobId, enqueuedJobId });
}

View file

@ -0,0 +1,40 @@
import { createHmac } from 'crypto';
import type { JobType, JobPayload } from '@/types';
import { db, cloudJobs } from '@/db';
import { enqueue } from '@/lib';
export function verifySignature(
body: string,
signature: string,
secret: string,
): boolean {
const expectedSignature = createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
const receivedSignature = signature.replace('sha256=', '');
return expectedSignature === receivedSignature;
}
export async function createAndEnqueueJob<T extends JobType>(
type: T,
payload: JobPayload<T>,
): Promise<{ jobId: number; enqueuedJobId: string }> {
const [job] = await db
.insert(cloudJobs)
.values({ type, payload, status: 'pending' })
.returning();
if (!job) {
throw new Error('Failed to create `cloudJobs` record.');
}
const enqueuedJob = await enqueue({ jobId: job.id, type, payload });
console.log(`🔗 Enqueued ${type} job (id: ${job.id}) ->`, payload);
if (!enqueuedJob.id) {
throw new Error('Failed to get enqueued job ID.');
}
return { jobId: job.id, enqueuedJobId: enqueuedJob.id };
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
verifySignature,
handleIssueEvent,
handlePullRequestEvent,
handleIssueCommentEvent,
handlePullRequestReviewCommentEvent,
} from './handlers';
export async function POST(request: NextRequest) {
try {
const signature = request.headers.get('x-hub-signature-256');
if (!signature) {
return NextResponse.json({ error: 'missing_signature' }, { status: 400 });
}
const body = await request.text();
if (!verifySignature(body, signature, process.env.GH_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: 'invalid_signature' }, { status: 401 });
}
const event = request.headers.get('x-github-event');
console.log(`🛎️ GitHub Webhook Event -> ${event}`);
if (event === 'issues') {
return await handleIssueEvent(body);
} else if (event === 'pull_request') {
return await handlePullRequestEvent(body);
} else if (event === 'issue_comment') {
return await handleIssueCommentEvent(body);
} else if (event === 'pull_request_review_comment') {
return await handlePullRequestReviewCommentEvent(body);
} else {
return NextResponse.json({ message: 'event_ignored' });
}
} catch (error) {
console.error('GitHub Webhook Error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'bad_request', details: error.errors },
{ status: 400 },
);
}
return NextResponse.json(
{ error: 'internal_server_error' },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Cloud Agents',
description: 'Roo Code task execution service',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello, World!</div>;
}

View file

@ -0,0 +1,11 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { schema } from './schema';
export const db = drizzle(
postgres(process.env.DATABASE_URL!, { prepare: false }),
{ schema },
);
export * from './schema';

View file

@ -0,0 +1,34 @@
import { pgTable, text, timestamp, integer, jsonb } from 'drizzle-orm/pg-core';
import type { JobType, JobStatus, JobPayload } from '@/types';
/**
* cloudJobs
*/
export const cloudJobs = pgTable('cloud_jobs', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
type: text().notNull().$type<JobType>(),
status: text().notNull().default('pending').$type<JobStatus>(),
payload: jsonb().notNull().$type<JobPayload>(),
result: jsonb(),
error: text(),
slackThreadTs: text('slack_thread_ts'),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export type CloudJob = typeof cloudJobs.$inferSelect;
export type InsertCloudJob = typeof cloudJobs.$inferInsert;
export type UpdateCloudJob = Partial<Omit<CloudJob, 'id' | 'createdAt'>>;
/**
* schema
*/
export const schema = {
cloudJobs,
};

View file

@ -0,0 +1,95 @@
// npx vitest src/lib/__tests__/controller.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockQueue = {
getWaiting: vi.fn(() => Promise.resolve([])),
getActive: vi.fn(() => Promise.resolve([])),
close: vi.fn(() => Promise.resolve()),
on: vi.fn(),
};
const mockSpawn = vi.fn(() => ({
stdout: { pipe: vi.fn() },
stderr: { pipe: vi.fn() },
on: vi.fn(),
unref: vi.fn(),
}));
const mockCreateWriteStream = vi.fn(() => ({
end: vi.fn(),
}));
vi.mock('../redis', () => ({
redis: { host: 'localhost', port: 6379 },
}));
vi.mock('child_process', () => ({
spawn: mockSpawn,
}));
vi.mock('fs', () => ({
default: {
existsSync: vi.fn(() => false),
createWriteStream: mockCreateWriteStream,
},
}));
const mockQueueConstructor = vi.fn(() => mockQueue);
vi.mock('bullmq', () => ({
Queue: mockQueueConstructor,
}));
describe('WorkerController', () => {
let WorkerController: typeof import('../controller').WorkerController;
beforeEach(async () => {
vi.clearAllMocks();
const controllerModule = await import('../controller');
WorkerController = controllerModule.WorkerController;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create a Queue instance with correct configuration', () => {
const controller = new WorkerController();
expect(mockQueueConstructor).toHaveBeenCalledWith('roomote', {
connection: { host: 'localhost', port: 6379 },
});
expect(controller).toBeDefined();
});
it('should handle queue monitoring without errors', async () => {
const controller = new WorkerController();
await controller.start();
expect(controller).toBeDefined();
await controller.stop();
expect(mockQueue.close).toHaveBeenCalled();
});
it('should handle worker spawning logic', () => {
const controller = new WorkerController();
expect(mockSpawn).toBeDefined();
expect(mockCreateWriteStream).toBeDefined();
expect(controller).toBeDefined();
});
it('should track running state correctly', async () => {
const controller = new WorkerController();
expect(controller.isRunning).toBeFalsy();
await controller.start();
expect(controller.isRunning).toBeTruthy();
await controller.stop();
expect(controller.isRunning).toBeFalsy();
});
});

View file

@ -0,0 +1,159 @@
import { spawn } from 'child_process';
import fs from 'fs';
import { Queue } from 'bullmq';
import { redis } from './redis';
export class WorkerController {
private readonly POLL_INTERVAL_MS = 5000;
private readonly MAX_WORKERS = 5;
private queue: Queue;
public isRunning = false;
private pollingInterval: NodeJS.Timeout | null = null;
private activeWorkers = new Set<string>();
constructor() {
this.queue = new Queue('roomote', { connection: redis });
}
async start() {
if (this.isRunning) {
console.log('Controller is already running');
return;
}
this.isRunning = true;
console.log('Worker controller started');
await this.checkAndSpawnWorker();
this.pollingInterval = setInterval(async () => {
await this.checkAndSpawnWorker();
}, this.POLL_INTERVAL_MS);
}
async stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
console.log('Stopping worker controller...');
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
await this.queue.close();
console.log('Worker controller stopped');
}
private async checkAndSpawnWorker() {
try {
const waiting = await this.queue.getWaiting();
const active = await this.queue.getActive();
const waitingCount = waiting.length;
const activeCount = active.length;
console.log(
`Queue status: ${waitingCount} waiting, ${activeCount} active, ${this.activeWorkers.size} spawned workers`,
);
if (waitingCount > 0 && this.activeWorkers.size < this.MAX_WORKERS) {
await this.spawnWorker();
}
} catch (error) {
console.error('Error checking queue status:', error);
}
}
private async spawnWorker() {
const workerId = `worker-${Date.now()}`;
try {
console.log(`Spawning worker: ${workerId}`);
const isRunningInDocker = fs.existsSync('/.dockerenv');
const dockerArgs = [
`--name roomote-${workerId}`,
'--rm',
'--network roo-code-cloud_default',
'-e HOST_EXECUTION_METHOD=docker',
`-e GH_TOKEN=${process.env.GH_TOKEN}`,
`-e DATABASE_URL=${process.env.DATABASE_URL}`,
`-e REDIS_URL=${process.env.REDIS_URL}`,
`-e NODE_ENV=${process.env.NODE_ENV}`,
'-v /var/run/docker.sock:/var/run/docker.sock',
'-v /tmp/roomote:/var/log/roomote',
];
const cliCommand = 'pnpm worker';
const command = isRunningInDocker
? `docker run ${dockerArgs.join(' ')} roomote-worker sh -c "${cliCommand}"`
: cliCommand;
console.log('Spawning worker with command:', command);
const childProcess = spawn('sh', ['-c', command], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
if (childProcess.stdout) {
childProcess.stdout.on('data', (data) => {
console.log(data.toString());
});
}
if (childProcess.stderr) {
childProcess.stderr.on('data', (data) => {
console.error(data.toString());
});
}
this.activeWorkers.add(workerId);
childProcess.on('exit', (code) => {
console.log(`Worker ${workerId} exited with code ${code}`);
this.activeWorkers.delete(workerId);
});
childProcess.on('error', (error) => {
console.error(`Worker ${workerId} error:`, error);
this.activeWorkers.delete(workerId);
});
// Detach the process so it can run independently.
childProcess.unref();
} catch (error) {
console.error(`Failed to spawn worker ${workerId}:`, error);
this.activeWorkers.delete(workerId);
}
}
}
// Only run if this file is executed directly (not imported).
if (import.meta.url === `file://${process.argv[1]}`) {
const controller = new WorkerController();
process.on('SIGTERM', async () => {
console.log('SIGTERM -> shutting down controller gracefully...');
await controller.stop();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT -> shutting down controller gracefully...');
await controller.stop();
process.exit(0);
});
controller.start().catch((error) => {
console.error('Failed to start controller:', error);
process.exit(1);
});
}

View file

@ -0,0 +1,2 @@
export { redis } from './redis';
export { enqueue } from './queue';

133
apps/roomote/src/lib/job.ts Normal file
View file

@ -0,0 +1,133 @@
import { eq } from 'drizzle-orm';
import { Job } from 'bullmq';
import { db, cloudJobs, type UpdateCloudJob } from '@/db';
import type { JobType, JobStatus, JobParams, JobPayload } from '@/types';
import { fixGitHubIssue } from './jobs/fixGitHubIssue';
import { processPullRequestComment } from './jobs/processPullRequestComment';
import { processIssueComment } from './jobs/processIssueComment';
export async function processJob<T extends JobType>({
data: { type, payload, jobId },
...job
}: Job<JobParams<T>>) {
console.log(
`[${job.name} | ${job.id}] Processing job ${jobId} of type ${type}`,
);
try {
let result: unknown;
switch (type) {
case 'github.issue.fix':
result = await fixGitHubIssue(
payload as JobPayload<'github.issue.fix'>,
{
onTaskStarted: async (
slackThreadTs: string | null,
_rooTaskId: string,
) => {
if (slackThreadTs) {
await updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
);
}
},
},
);
break;
case 'github.issue.comment.respond':
result = await processIssueComment(
payload as JobPayload<'github.issue.comment.respond'>,
{
onTaskStarted: async (
slackThreadTs: string | null,
_rooTaskId: string,
) => {
if (slackThreadTs) {
await updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
);
}
},
},
);
break;
case 'github.pr.comment.respond':
result = await processPullRequestComment(
payload as JobPayload<'github.pr.comment.respond'>,
{
onTaskStarted: async (
slackThreadTs: string | null,
_rooTaskId: string,
) => {
if (slackThreadTs) {
await updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
);
}
},
},
);
break;
default:
throw new Error(`Unknown job type: ${type}`);
}
await updateJobStatus(jobId, 'completed', result);
console.log(
`[${job.name} | ${job.id}] Job ${jobId} completed successfully`,
);
} catch (error) {
console.error(`[${job.name} | ${job.id}] Job ${jobId} failed:`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
await updateJobStatus(jobId, 'failed', undefined, errorMessage);
throw error; // Re-throw to mark job as failed in BullMQ.
}
}
async function updateJobStatus(
jobId: number,
status: JobStatus,
result?: unknown,
error?: string,
slackThreadTs?: string,
) {
const values: UpdateCloudJob = { status };
if (status === 'processing') {
values.startedAt = new Date();
} else if (status === 'completed' || status === 'failed') {
values.completedAt = new Date();
if (result) {
values.result = result;
}
if (error) {
values.error = error;
}
}
if (slackThreadTs) {
values.slackThreadTs = slackThreadTs;
}
await db.update(cloudJobs).set(values).where(eq(cloudJobs.id, jobId));
}

View file

@ -0,0 +1,59 @@
import * as path from 'path';
import * as os from 'node:os';
import type { JobType, JobPayload } from '@/types';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { Logger } from '../logger';
const jobType: JobType = 'github.issue.fix';
type FixGitHubIssueJobPayload = JobPayload<'github.issue.fix'>;
export async function fixGitHubIssue(
jobPayload: FixGitHubIssueJobPayload,
callbacks?: RunTaskCallbacks,
): Promise<{
repo: string;
issue: number;
result: unknown;
}> {
const prompt = `
Fix the following GitHub issue:
Repository: ${jobPayload.repo}
Issue #${jobPayload.issue}: ${jobPayload.title}
Description:
${jobPayload.body}
${jobPayload.labels && jobPayload.labels.length > 0 ? `Labels: ${jobPayload.labels.join(', ')}` : ''}
Please analyze the issue, understand what needs to be fixed, and implement a solution.
When you're finished:
- Create a git branch to store your work (git checkout -b fix-${jobPayload.issue})
- Commit your changes to this branch (git commit -m "Fixes #${jobPayload.issue}")
- Push your branch to the remote repository (git push --set-upstream origin fix-${jobPayload.issue})
- Submit a pull request using the "gh" command line tool (gh pr create --title "Fixes #${jobPayload.issue}\n\n[Your PR description here.]" --fill)
Your job isn't done until you've created a pull request. Try to solve any git issues that arise while creating your branch and submitting your pull request.
`.trim();
const { repo, issue } = jobPayload;
const result = await runTask({
jobType,
jobPayload,
prompt,
publish: async () => {},
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
}),
callbacks,
});
return { repo, issue, result };
}

View file

@ -0,0 +1,71 @@
import * as path from 'path';
import * as os from 'node:os';
import type { JobType, JobPayload } from '@/types';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { Logger } from '../logger';
const jobType: JobType = 'github.issue.comment.respond';
type ProcessIssueCommentJobPayload = JobPayload<'github.issue.comment.respond'>;
export async function processIssueComment(
jobPayload: ProcessIssueCommentJobPayload,
callbacks?: RunTaskCallbacks,
): Promise<{
repo: string;
issueNumber: number;
commentId: number;
result: unknown;
}> {
const prompt = `
Respond to the following GitHub Issue comment:
Repository: ${jobPayload.repo}
Issue #${jobPayload.issueNumber}: ${jobPayload.issueTitle}
Issue Description:
${jobPayload.issueBody || 'No description provided'}
Comment by @${jobPayload.commentAuthor}:
${jobPayload.commentBody}
Comment URL: ${jobPayload.commentUrl}
Please analyze the comment and provide a helpful response. The comment mentions @roomote, which means the user wants you to engage with their question or request.
Instructions:
1. Read and understand the context of the issue and the specific comment
2. Provide a thoughtful, helpful response to the comment
3. If the comment asks a question, try to answer it based on your knowledge
4. If the comment requests an action, explain what you can do or suggest next steps
5. If the comment is unclear, ask for clarification
6. Use the GitHub CLI or API to respond to the comment with your message
Your goal is to be helpful and engage meaningfully with the community member who mentioned @roomote.
Use the "gh" command line tool to respond to the comment:
gh api repos/${jobPayload.repo}/issues/comments/${jobPayload.commentId} --method PATCH --field body="Your response here"
Or create a new comment response:
gh api repos/${jobPayload.repo}/issues/${jobPayload.issueNumber}/comments --method POST --field body="Your response here"
`.trim();
const { repo, issueNumber, commentId } = jobPayload;
const result = await runTask({
jobType,
jobPayload,
prompt,
publish: async () => {},
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
}),
callbacks,
});
return { repo, issueNumber, commentId, result };
}

View file

@ -0,0 +1,85 @@
import * as path from 'path';
import * as os from 'node:os';
import type { JobType, JobPayload } from '@/types';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { Logger } from '../logger';
const jobType: JobType = 'github.pr.comment.respond';
type ProcessPullRequestCommentJobPayload =
JobPayload<'github.pr.comment.respond'>;
export async function processPullRequestComment(
jobPayload: ProcessPullRequestCommentJobPayload,
callbacks?: RunTaskCallbacks,
): Promise<{
repo: string;
prNumber: number;
commentId: number;
result: unknown;
}> {
const prompt = `
Process the following GitHub Pull Request comment:
Repository: ${jobPayload.repo}
Pull Request #${jobPayload.prNumber}: ${jobPayload.prTitle}
PR Description:
${jobPayload.prBody || 'No description provided'}
Comment by @${jobPayload.commentAuthor}:
${jobPayload.commentBody}
Comment Type: ${jobPayload.commentType}
Comment URL: ${jobPayload.commentUrl}
PR Branch: ${jobPayload.prBranch}
Base Branch: ${jobPayload.baseRef}
Please analyze the comment and understand what changes are being requested. Then implement the requested changes directly on the PR branch AND respond to the comment.
Instructions:
1. First, respond to the comment to acknowledge the request and explain what you'll do
2. Check out the PR branch: git checkout ${jobPayload.prBranch}
3. Analyze the comment in the context of the pull request
4. Make the appropriate changes based on the comment
5. Commit your changes with a clear message referencing the comment
6. Push the changes to the same PR branch: git push origin ${jobPayload.prBranch}
7. After completing the changes, update your response or add a follow-up comment with the results
The comment mentions @roomote, which means the user wants you to process this request. Make sure to:
- Respond to the comment first to acknowledge the request
- Understand the context of the PR and the specific request in the comment
- Implement the requested changes thoughtfully
- Test your changes if applicable
- Write clear commit messages that reference the comment
- Provide updates on the progress and results
Use the GitHub CLI to respond to the comment:
gh api repos/${jobPayload.repo}/issues/comments/${jobPayload.commentId} --method PATCH --field body="Your response here"
Or create a new comment response:
gh api repos/${jobPayload.repo}/issues/${jobPayload.prNumber}/comments --method POST --field body="Your response here"
Do not create a new pull request - work directly on the existing PR branch.
`.trim();
const { repo, prNumber, commentId } = jobPayload;
const result = await runTask({
jobType,
jobPayload,
prompt,
publish: async () => {},
logger: new Logger({
logDir: path.resolve(os.tmpdir(), 'logs'),
filename: 'worker.log',
tag: 'worker',
}),
callbacks,
});
return { repo, prNumber, commentId, result };
}

View file

@ -0,0 +1,86 @@
import * as fs from 'fs';
import * as path from 'path';
enum LogLevel {
INFO = 'INFO',
ERROR = 'ERROR',
WARN = 'WARN',
DEBUG = 'DEBUG',
}
interface LoggerOptions {
logDir: string;
filename: string;
tag: string;
}
export class Logger {
private logStream: fs.WriteStream | undefined;
private logFilePath: string;
private tag: string;
constructor({ logDir, filename, tag }: LoggerOptions) {
this.tag = tag;
this.logFilePath = path.join(logDir, filename);
this.initializeLogger(logDir);
}
private initializeLogger(logDir: string): void {
try {
fs.mkdirSync(logDir, { recursive: true });
} catch (error) {
console.error(`Failed to create log directory ${logDir}:`, error);
}
try {
this.logStream = fs.createWriteStream(this.logFilePath, { flags: 'a' });
} catch (error) {
console.error(`Failed to create log file ${this.logFilePath}:`, error);
}
}
private writeToLog(level: LogLevel, message: string, ...args: unknown[]) {
try {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp} | ${level} | ${this.tag}] ${message} ${
args.length > 0 ? JSON.stringify(args) : ''
}\n`;
console.log(logLine.trim());
if (this.logStream) {
this.logStream.write(logLine);
}
} catch (error) {
console.error(`Failed to write to log file ${this.logFilePath}:`, error);
}
}
public info(message: string, ...args: unknown[]): void {
this.writeToLog(LogLevel.INFO, message, ...args);
}
public error(message: string, ...args: unknown[]): void {
this.writeToLog(LogLevel.ERROR, message, ...args);
}
public warn(message: string, ...args: unknown[]): void {
this.writeToLog(LogLevel.WARN, message, ...args);
}
public debug(message: string, ...args: unknown[]): void {
this.writeToLog(LogLevel.DEBUG, message, ...args);
}
public log(message: string, ...args: unknown[]): void {
this.info(message, ...args);
}
public close(): void {
if (this.logStream) {
this.logStream.end();
this.logStream = undefined;
}
}
}

View file

@ -0,0 +1,23 @@
import { Queue, Job } from 'bullmq';
import type { JobTypes, JobPayload, JobParams } from '@/types';
import { redis } from './redis';
const queue = new Queue('roomote', {
connection: redis,
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
},
});
export async function enqueue<T extends keyof JobTypes>(
params: JobParams<T>,
): Promise<Job<JobPayload<T>>> {
return queue.add(params.type, params, {
jobId: `${params.type}-${params.jobId}`,
});
}

View file

@ -0,0 +1,8 @@
import IORedis from 'ioredis';
export const redis = new IORedis(
process.env.REDIS_URL || 'redis://localhost:6379',
{
maxRetriesPerRequest: null,
},
);

View file

@ -0,0 +1,334 @@
import * as path from 'path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import pWaitFor from 'p-wait-for';
import { execa } from 'execa';
import {
type TaskEvent,
TaskCommandName,
RooCodeEventName,
IpcMessageType,
EVALS_SETTINGS,
} from '@roo-code/types';
import { IpcClient } from '@roo-code-cloud/ipc';
import type { JobPayload, JobType } from '@/types';
import { Logger } from './logger';
import { isDockerContainer } from './utils';
import { SlackNotifier } from './slack';
const TIMEOUT = 30 * 60 * 1_000;
class SubprocessTimeoutError extends Error {
constructor(timeout: number) {
super(`Subprocess timeout after ${timeout}ms`);
this.name = 'SubprocessTimeoutError';
}
}
export type RunTaskCallbacks = {
onTaskStarted?: (
slackThreadTs: string | null,
rooTaskId: string,
) => Promise<void>;
onTaskAborted?: (slackThreadTs: string | null) => Promise<void>;
onTaskCompleted?: (
slackThreadTs: string | null,
success: boolean,
duration: number,
rooTaskId?: string,
) => Promise<void>;
onTaskTimedOut?: (slackThreadTs: string | null) => Promise<void>;
onClientDisconnected?: (slackThreadTs: string | null) => Promise<void>;
};
type RunTaskOptions<T extends JobType> = {
jobType: T;
jobPayload: JobPayload<T>;
prompt: string;
publish: (taskEvent: TaskEvent) => Promise<void>;
logger: Logger;
callbacks?: RunTaskCallbacks;
};
export const runTask = async <T extends JobType>({
jobType,
jobPayload,
prompt,
publish,
logger,
callbacks,
}: RunTaskOptions<T>) => {
const workspacePath = '/roo/repos/Roo-Code'; // findGitRoot(process.cwd())
const ipcSocketPath = path.resolve(
os.tmpdir(),
`${crypto.randomUUID().slice(0, 8)}.sock`,
);
const env = { ROO_CODE_IPC_SOCKET_PATH: ipcSocketPath };
const controller = new AbortController();
const cancelSignal = controller.signal;
const containerized = isDockerContainer();
const codeCommand = containerized
? `xvfb-run --auto-servernum --server-num=1 code --wait --log trace --disable-workspace-trust --disable-gpu --disable-lcd-text --no-sandbox --user-data-dir /roo/.vscode --password-store="basic" -n ${workspacePath}`
: `code --disable-workspace-trust -n ${workspacePath}`;
logger.info(codeCommand);
// Sleep for a random amount of time between 5 and 10 seconds, unless we're
// running in a container, in which case there are no issues with flooding
// VSCode with new windows.
if (!containerized) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 5_000 + 5_000),
);
}
const subprocess = execa({
env,
shell: '/bin/bash',
cancelSignal,
})`${codeCommand}`;
// If debugging, add `--verbose` to `command` and uncomment the following line.
// subprocess.stdout.pipe(process.stdout)
// Give VSCode some time to spawn before connecting to its unix socket.
await new Promise((resolve) => setTimeout(resolve, 3_000));
let client: IpcClient | undefined = undefined;
let attempts = 5;
while (true) {
try {
client = new IpcClient(ipcSocketPath);
await pWaitFor(() => client!.isReady, { interval: 250, timeout: 1_000 });
break;
} catch (_error) {
client?.disconnect();
attempts--;
if (attempts <= 0) {
logger.error(`unable to connect to IPC socket -> ${ipcSocketPath}`);
throw new Error('Unable to connect.');
}
}
}
let taskStartedAt = Date.now();
let taskFinishedAt: number | undefined;
let taskAbortedAt: number | undefined;
let taskTimedOut: boolean = false;
let rooTaskId: string | undefined;
let isClientDisconnected = false;
const slackNotifier = new SlackNotifier(logger);
let slackThreadTs: string | null = null;
const ignoreEvents: Record<'broadcast' | 'log', RooCodeEventName[]> = {
broadcast: [RooCodeEventName.Message],
log: [
RooCodeEventName.TaskTokenUsageUpdated,
RooCodeEventName.TaskAskResponded,
],
};
client.on(IpcMessageType.TaskEvent, async (taskEvent) => {
const { eventName, payload } = taskEvent;
// Publish all events except for these to Redis.
if (!ignoreEvents.broadcast.includes(eventName)) {
await publish({ ...taskEvent });
}
// Log all events except for these.
// For message events we only log non-partial messages.
if (
!ignoreEvents.log.includes(eventName) &&
(eventName !== RooCodeEventName.Message ||
payload[0].message.partial !== true)
) {
logger.info(`${eventName} ->`, payload);
}
if (eventName === RooCodeEventName.TaskStarted) {
taskStartedAt = Date.now();
rooTaskId = payload[0];
if (rooTaskId) {
slackThreadTs = await slackNotifier.postTaskStarted({
jobType,
jobPayload,
rooTaskId,
});
if (callbacks?.onTaskStarted) {
await callbacks.onTaskStarted(slackThreadTs, rooTaskId);
}
}
}
if (eventName === RooCodeEventName.TaskAborted) {
taskAbortedAt = Date.now();
if (slackThreadTs) {
await slackNotifier.postTaskUpdated(
slackThreadTs,
'Task was aborted',
'warning',
);
}
if (callbacks?.onTaskAborted) {
await callbacks.onTaskAborted(slackThreadTs);
}
}
if (eventName === RooCodeEventName.TaskCompleted) {
taskFinishedAt = Date.now();
if (slackThreadTs) {
await slackNotifier.postTaskCompleted(
slackThreadTs,
true,
taskFinishedAt - taskStartedAt,
rooTaskId,
);
}
if (callbacks?.onTaskCompleted) {
await callbacks.onTaskCompleted(
slackThreadTs,
true,
taskFinishedAt - taskStartedAt,
rooTaskId,
);
}
}
});
client.on(IpcMessageType.Disconnect, async () => {
logger.info(`disconnected from IPC socket -> ${ipcSocketPath}`);
isClientDisconnected = true;
});
client.sendCommand({
commandName: TaskCommandName.StartNewTask,
data: {
configuration: {
...EVALS_SETTINGS,
openRouterApiKey: process.env.OPENROUTER_API_KEY,
},
text: prompt,
newTab: true,
},
});
try {
await pWaitFor(
() => !!taskFinishedAt || !!taskAbortedAt || isClientDisconnected,
{
interval: 1_000,
timeout: TIMEOUT,
},
);
} catch (_error) {
taskTimedOut = true;
logger.error('time limit reached');
if (slackThreadTs) {
await slackNotifier.postTaskUpdated(
slackThreadTs,
'Task timed out after 30 minutes',
'error',
);
}
if (callbacks?.onTaskTimedOut) {
await callbacks.onTaskTimedOut(slackThreadTs);
}
if (rooTaskId && !isClientDisconnected) {
logger.info('cancelling task');
client.sendCommand({
commandName: TaskCommandName.CancelTask,
data: rooTaskId,
});
await new Promise((resolve) => setTimeout(resolve, 5_000)); // Allow some time for the task to cancel.
}
taskFinishedAt = Date.now();
}
if (!taskFinishedAt && !taskTimedOut) {
logger.error('client disconnected before task finished');
if (slackThreadTs) {
await slackNotifier.postTaskUpdated(
slackThreadTs,
'Client disconnected before task completion',
'error',
);
}
if (callbacks?.onClientDisconnected) {
await callbacks.onClientDisconnected(slackThreadTs);
}
throw new Error('Client disconnected before task completion.');
}
if (rooTaskId && !isClientDisconnected) {
logger.info('closing task');
client.sendCommand({
commandName: TaskCommandName.CloseTask,
data: rooTaskId,
});
await new Promise((resolve) => setTimeout(resolve, 2_000)); // Allow some time for the window to close.
}
if (!isClientDisconnected) {
logger.info('disconnecting client');
client.disconnect();
}
logger.info('waiting for subprocess to finish');
controller.abort();
// Wait for subprocess to finish gracefully, with a timeout.
const SUBPROCESS_TIMEOUT = 10_000;
try {
await Promise.race([
subprocess,
new Promise((_, reject) =>
setTimeout(
() => reject(new SubprocessTimeoutError(SUBPROCESS_TIMEOUT)),
SUBPROCESS_TIMEOUT,
),
),
]);
logger.info('subprocess finished gracefully');
} catch (error) {
if (error instanceof SubprocessTimeoutError) {
logger.error('subprocess did not finish within timeout, force killing');
try {
if (subprocess.kill('SIGKILL')) {
logger.info('SIGKILL sent to subprocess');
} else {
logger.error('failed to send SIGKILL to subprocess');
}
} catch (killError) {
logger.error('subprocess.kill(SIGKILL) failed:', killError);
}
} else {
throw error;
}
}
logger.close();
};

View file

@ -0,0 +1,159 @@
import { JobPayload, JobType } from '@/types';
import { Logger } from './logger';
export interface SlackMessage {
text: string;
blocks?: unknown[];
attachments?: unknown[];
thread_ts?: string;
channel?: string;
}
export interface SlackResponse {
ok: boolean;
channel?: string;
ts?: string;
error?: string;
message?: Record<string, unknown>;
}
export class SlackNotifier {
private readonly logger?: Logger;
private readonly token: string;
constructor(logger?: Logger, token: string = process.env.SLACK_API_TOKEN!) {
this.logger = logger;
this.token = token;
}
private async postMessage(message: SlackMessage): Promise<string | null> {
try {
const messageWithChannel = {
...message,
channel: message.channel || '#roomote-control',
};
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.token}`,
},
body: JSON.stringify(messageWithChannel),
});
if (!response.ok) {
this.logger?.error(
`Slack API failed: ${response.status} ${response.statusText}`,
);
return null;
}
const result: SlackResponse = await response.json();
if (!result.ok) {
this.logger?.error(`Slack API error: ${result.error}`);
}
return result.ts ?? null;
} catch (error) {
this.logger?.error('Failed to send Slack message:', error);
return null;
}
}
public async postTaskStarted<T extends JobType>({
jobType,
jobPayload,
}: {
jobType: T;
jobPayload: JobPayload<T>;
rooTaskId: string;
}) {
switch (jobType) {
case 'github.issue.fix': {
const payload = jobPayload as JobPayload<'github.issue.fix'>;
return await this.postMessage({
text: `🚀 Task Started`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `🚀 *Task Started*\nAttempting to fix <https://github.com/RooCodeInc/Roo-Code/issues/${payload.issue}|Issue #${payload.issue}>`,
},
},
],
});
}
case 'github.issue.comment.respond': {
const payload =
jobPayload as JobPayload<'github.issue.comment.respond'>;
return await this.postMessage({
text: `🚀 Task Started`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `🚀 *Task Started*\nResponding to comment on <https://github.com/${payload.repo}/issues/${payload.issueNumber}|Issue #${payload.issueNumber}>\n*Comment:* ${payload.commentBody.slice(0, 100)}${payload.commentBody.length > 100 ? '...' : ''}`,
},
},
],
});
}
case 'github.pr.comment.respond': {
const payload = jobPayload as JobPayload<'github.pr.comment.respond'>;
return await this.postMessage({
text: `🚀 Task Started`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `🚀 *Task Started*\nResponding to comment on <https://github.com/${payload.repo}/pull/${payload.prNumber}|PR #${payload.prNumber}>\n*Comment:* ${payload.commentBody.slice(0, 100)}${payload.commentBody.length > 100 ? '...' : ''}`,
},
},
],
});
}
default:
throw new Error(`Unknown job type: ${jobType}`);
}
}
public async postTaskUpdated(
threadTs: string,
text: string,
status?: 'info' | 'success' | 'warning' | 'error',
): Promise<void> {
const emoji = { info: '', success: '✅', warning: '⚠️', error: '❌' }[
status || 'info'
];
await this.postMessage({ text: `${emoji} ${text}`, thread_ts: threadTs });
}
public async postTaskCompleted(
threadTs: string,
success: boolean,
duration: number,
taskId?: string,
): Promise<void> {
const status = success ? '✅ Completed' : '❌ Failed';
const durationText = `${Math.round(duration / 1000)}s`;
await this.postMessage({
text: `${status} Task finished in ${durationText}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${status}*\n*Task ID:* ${taskId || 'Unknown'}\n*Duration:* ${durationText}`,
},
},
],
thread_ts: threadTs,
});
}
}

View file

@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as path from 'path';
export const isDockerContainer = () => {
try {
return fs.existsSync('/.dockerenv');
} catch (_error) {
return false;
}
};
/**
* Traverses up the directory tree to find an ancestor directory that contains a .git directory
* @param startPath The starting directory path
* @returns The path to the git repository root
* @throws Error if no .git directory is found
*/
export const findGitRoot = (startPath: string): string => {
let currentPath = path.resolve(startPath);
const root = path.parse(currentPath).root;
while (currentPath !== root) {
const gitPath = path.join(currentPath, '.git');
if (fs.existsSync(gitPath) && fs.statSync(gitPath).isDirectory()) {
return currentPath;
}
currentPath = path.dirname(currentPath);
}
const gitPath = path.join(root, '.git');
if (fs.existsSync(gitPath) && fs.statSync(gitPath).isDirectory()) {
return root;
}
throw new Error('No .git directory found in any ancestor directory');
};

View file

@ -0,0 +1,72 @@
import { Worker } from 'bullmq';
import { redis } from './redis';
import { processJob } from './job';
// docker compose build worker
// docker run \
// --name roomote-worker \
// --rm \
// --interactive \
// --tty \
// --network roo-code-cloud_default \
// -e HOST_EXECUTION_METHOD=docker \
// -e GH_TOKEN=$GH_TOKEN \
// -e DATABASE_URL=postgresql://postgres:password@db:5432/cloud_agents \
// -e REDIS_URL=redis://redis:6379 \
// -e NODE_ENV=production \
// -v /var/run/docker.sock:/var/run/docker.sock \
// -v /tmp/roomote:/var/log/roomote \
// roomote-worker sh -c "bash"
async function processSingleJob() {
const worker = new Worker('roomote', undefined, {
autorun: false,
connection: redis,
lockDuration: 30 * 60 * 1_000, // 30 minutes
});
const token = crypto.randomUUID();
try {
const job = await worker.getNextJob(token);
if (!job) {
console.log('No jobs available, exiting...');
await worker.close();
process.exit(0);
}
console.log(`Processing job ${job.id}...`);
try {
await processJob(job);
await job.moveToCompleted(undefined, token, false);
console.log(`Job ${job.id} completed successfully`);
} catch (error) {
await job.moveToFailed(error as Error, token, false);
console.error(`Job ${job.id} failed:`, error);
}
} catch (error) {
console.error('Error processing job:', error);
} finally {
await worker.close();
process.exit(0);
}
}
process.on('SIGTERM', async () => {
console.log('SIGTERM -> shutting down gracefully...');
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT -> shutting down gracefully...');
process.exit(0);
});
if (!process.env.GH_TOKEN) {
throw new Error('GH_TOKEN is not set');
}
processSingleJob();

View file

@ -0,0 +1,61 @@
import { z } from 'zod';
export const createJobSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('github.issue.fix'),
payload: z.object({
repo: z.string(),
issue: z.number(),
title: z.string(),
body: z.string(),
labels: z.array(z.string()).optional(),
}),
}),
z.object({
type: z.literal('github.issue.comment.respond'),
payload: z.object({
repo: z.string(),
issueNumber: z.number(),
issueTitle: z.string(),
issueBody: z.string(),
commentId: z.number(),
commentBody: z.string(),
commentAuthor: z.string(),
commentUrl: z.string(),
}),
}),
z.object({
type: z.literal('github.pr.comment.respond'),
payload: z.object({
repo: z.string(),
prNumber: z.number(),
prTitle: z.string(),
prBody: z.string(),
prBranch: z.string(),
baseRef: z.string(),
commentId: z.number(),
commentBody: z.string(),
commentAuthor: z.string(),
commentType: z.enum(['issue_comment', 'review_comment']),
commentUrl: z.string(),
}),
}),
]);
export type CreateJob = z.infer<typeof createJobSchema>;
export type JobTypes = {
[K in CreateJob['type']]: Extract<CreateJob, { type: K }>['payload'];
};
export type JobType = keyof JobTypes;
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
export type JobPayload<T extends JobType = JobType> = JobTypes[T];
export type JobParams<T extends JobType> = {
jobId: number;
type: T;
payload: JobPayload<T>;
};

View file

@ -0,0 +1,15 @@
{
"extends": "@roo-code-cloud/config-typescript/nextjs.json",
"compilerOptions": {
"types": ["vitest/globals"],
"paths": { "@/*": ["./src/*"] }
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
"drizzle.config.ts"
],
"exclude": ["node_modules"]
}

View file

@ -5,9 +5,5 @@ export default defineConfig({
globals: true,
watch: false,
reporters: ['dot'],
coverage: {
include: ['src/**/*'],
exclude: ['src/**/*.stories.{js,jsx,ts,tsx}', '**/*.d.ts'],
},
},
});

4
apps/web/.gitignore vendored
View file

@ -1,7 +1,3 @@
# next.js
/.next
/out
# docker
.docker/*
!.docker/scripts

View file

@ -1,42 +0,0 @@
name: roo-code-cloud
services:
postgres:
container_name: roo-code-cloud-postgres
image: postgres:17.5
ports:
- '5432:5432'
volumes:
- ./.docker/data/postgres:/var/lib/postgresql/data
- ./.docker/scripts/postgres:/docker-entrypoint-initdb.d
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DATABASES=roo_code_development,roo_code_test
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d roo_code_development']
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
clickhouse:
container_name: roo-code-cloud-clickhouse
image: clickhouse/clickhouse-server
ports:
- '8123:8123'
- '9000:9000'
volumes:
- ./.docker/data/clickhouse:/var/lib/clickhouse/
- ./.docker/logs/clickhouse:/var/log/clickhouse-server/
- ./.docker/scripts/clickhouse:/docker-entrypoint-initdb.d
environment:
- CLICKHOUSE_DB=default
- CLICKHOUSE_USER=default
- CLICKHOUSE_PASSWORD=password
healthcheck:
test: ['CMD-SHELL', 'wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1']
interval: 5s
timeout: 5s
retries: 5
start_period: 30s

View file

@ -1,74 +1,4 @@
import globals from 'globals';
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginReact from 'eslint-plugin-react';
import pluginNext from '@next/eslint-plugin-next';
// import turboPlugin from 'eslint-plugin-turbo';
import onlyWarn from 'eslint-plugin-only-warn';
import { nextJsConfig } from '@roo-code-cloud/config-eslint/next-js';
/** @type {import("eslint").Linter.Config} */
export default [
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
// {
// plugins: {
// turbo: turboPlugin,
// },
// rules: {
// 'turbo/no-undeclared-env-vars': 'warn',
// },
// },
{
plugins: {
onlyWarn,
},
},
{
ignores: ['dist/**', '.next'],
},
{
...pluginReact.configs.flat.recommended,
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
},
},
},
{
plugins: {
'@next/next': pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs['core-web-vitals'].rules,
},
},
{
plugins: {
'react-hooks': pluginReactHooks,
},
settings: { react: { version: 'detect' } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
'react/react-in-jsx-scope': 'off',
},
},
{
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
caughtErrorsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
},
},
];
export default [...nextJsConfig];

View file

@ -2,20 +2,17 @@
"name": "@roo-code-cloud/web",
"private": true,
"scripts": {
"lint": "eslint .",
"check-types": "tsc --noEmit --pretty",
"lint": "next lint --max-warnings 0",
"check-types": "tsc --noEmit",
"test": "dotenvx run -f .env.test -f .env -- vitest run",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"clean": "rimraf .next out coverage",
"clean": "rimraf .next .turbo out",
"db:generate": "dotenvx run -f .env.development -- drizzle-kit generate",
"db:migrate": "dotenvx run -f .env.development -- drizzle-kit migrate",
"db:push": "dotenvx run -f .env.development -- drizzle-kit push",
"db:test:push": "dotenvx run -f .env.test -- drizzle-kit push",
"db:up": "docker compose up -d --wait && pnpm db:push --force && pnpm db:test:push --force",
"db:down": "docker compose down",
"db:reset": "pnpm db:down && rimraf .docker/data .docker/logs && pnpm db:up"
"db:test:push": "dotenvx run -f .env.test -- drizzle-kit push"
},
"dependencies": {
"@clerk/localizations": "^3.16.3",
@ -81,15 +78,16 @@
"@eslint/js": "^9.27.0",
"@next/bundle-analyzer": "^15.3.3",
"@next/eslint-plugin-next": "^15.3.3",
"@roo-code-cloud/config-eslint": "workspace:^",
"@roo-code-cloud/config-typescript": "workspace:^",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.27",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.6",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^3.1.4",
"@vitest/expect": "^3.1.4",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"drizzle-kit": "^0.31.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-only-warn": "^1.1.0",
@ -103,6 +101,6 @@
"tailwindcss-animate": "^1.0.7",
"typescript-eslint": "^8.33.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.4"
"vitest": "^3.2.3"
}
}

View file

@ -4,11 +4,8 @@ import postgres from 'postgres';
import { Env } from '@/lib/server';
import * as schema from './schema';
const pgClient = postgres(Env.DATABASE_URL, { prepare: false });
const client = drizzle({ client: pgClient, schema });
let testDb: typeof client | undefined = undefined;
const postgresClient = postgres(Env.DATABASE_URL, { prepare: false });
const client = drizzle({ client: postgresClient, schema });
if (process.env.NODE_ENV === 'test') {
if (
@ -17,16 +14,12 @@ if (process.env.NODE_ENV === 'test') {
) {
throw new Error('DATABASE_URL is not a test database');
}
testDb = client;
}
const disconnect = async () => {
await pgClient.end();
};
const disconnect = async () => postgresClient.end();
type DatabaseOrTransaction =
| typeof client
| Parameters<Parameters<typeof client.transaction>[0]>[0];
export { client, testDb, disconnect, type DatabaseOrTransaction };
export { client, disconnect, type DatabaseOrTransaction };

View file

@ -12,7 +12,9 @@ export const Locales: Record<Locale, string> = {
fr: 'Français',
};
export const getClerkLocale = (locale: string) => {
type ClerkLocalization = typeof enUS;
export const getClerkLocale = (locale: string): ClerkLocalization => {
if (!isLocale(locale)) {
return enUS;
}

View file

@ -1,46 +1,16 @@
{
"extends": "@roo-code-cloud/config-typescript/nextjs.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"removeComments": true,
"preserveConstEnums": true,
"strict": true,
"alwaysStrict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"target": "es2017",
"outDir": "out",
"sourceMap": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"noEmit": true,
"isolatedModules": true,
"incremental": true,
"types": ["vitest/globals"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"plugins": [{ "name": "next" }]
"types": ["vitest/globals", "@testing-library/jest-dom"],
"paths": { "@/*": ["./src/*"] }
},
"exclude": ["./out/**/*", "./node_modules/**/*", ".next/**/*"],
"include": [
"**/*.mts",
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
]
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
"drizzle.config.ts",
"vitest*.ts"
],
"exclude": ["node_modules"]
}

View file

@ -1,42 +0,0 @@
import { sql } from 'drizzle-orm';
import { Env } from 'src/lib/server';
import { testDb, disconnect } from 'src/db/server';
async function resetTestDatabase() {
const db = testDb;
// Skip database reset if no database connection is available
if (!db) {
console.log('No database connection available, skipping database reset');
return;
}
try {
const tables = await db.execute<{ table_name: string }>(sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE';
`);
const tableNames = tables.map((t) => t.table_name);
for (const tableName of tableNames) {
await db.execute(sql`TRUNCATE TABLE "${sql.raw(tableName)}" CASCADE;`);
}
console.log(`[${Env.DATABASE_URL}] TRUNCATE ${tableNames.join(', ')}`);
} catch (error) {
console.error('Error resetting database:', error);
throw error;
}
}
export default async function () {
await resetTestDatabase();
return async () => {
await disconnect();
};
}

41
apps/web/vitest.config.ts Normal file
View file

@ -0,0 +1,41 @@
import { resolve } from 'path';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
test: {
globals: true,
watch: false,
reporters: ['dot'],
projects: [
{
extends: true,
test: {
name: 'server',
include: [
'src/**/*.test.{js,jsx,ts,tsx}',
'!src/{hooks,components}/**/*.test.{js,jsx,ts,tsx}',
],
environment: 'node',
globalSetup: './vitest.setup.server.ts',
},
},
{
extends: true,
test: {
name: 'client',
include: ['src/{hooks,components}/**/*.test.{js,jsx,ts,tsx}'],
environment: 'jsdom',
setupFiles: './vitest.setup.client.ts',
},
},
],
},
});

View file

@ -0,0 +1,38 @@
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { sql } from 'drizzle-orm';
let pgClient: ReturnType<typeof postgres> | undefined = undefined;
async function resetTestDatabase() {
pgClient = postgres(
'postgres://postgres:password@localhost:5432/roo_code_test',
{
prepare: false,
onnotice: () => {}, // Suppress NOTICE logs.
},
);
const db = drizzle({ client: pgClient });
const tables = await db.execute<{ table_name: string }>(sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE';
`);
const tableNames = tables.map((t) => t.table_name);
for (const tableName of tableNames) {
await db.execute(sql`TRUNCATE TABLE "${sql.raw(tableName)}" CASCADE;`);
}
}
export default async function () {
await resetTestDatabase();
return async () => {
await pgClient?.end();
};
}

View file

@ -1,37 +0,0 @@
import { defineWorkspace } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineWorkspace([
// Server-side tests (node environment).
{
plugins: [react(), tsconfigPaths()],
test: {
name: 'server',
globals: true,
include: [
'src/**/*.test.{js,jsx,ts,tsx}',
'!src/hooks/**/*.test.{js,jsx,ts,tsx}', // Exclude hooks tests.
'!src/components/**/*.test.{js,jsx,ts,tsx}', // Exclude component tests.
],
environment: 'node',
setupFiles: './vitest-setup.ts',
globalSetup: './vitest-global-setup.ts',
},
},
// Client-side tests (jsdom environment).
{
plugins: [react(), tsconfigPaths()],
test: {
name: 'client',
globals: true,
include: [
'src/hooks/**/*.test.{js,jsx,ts,tsx}',
'src/components/**/*.test.{js,jsx,ts,tsx}',
],
environment: 'jsdom',
setupFiles: './vitest-setup.ts',
// No globalSetup for client tests to avoid database connection issues.
},
},
]);

148
docker-compose.yml Normal file
View file

@ -0,0 +1,148 @@
name: roo-code-cloud
services:
base:
build:
context: ../../
dockerfile: .docker/Dockerfile.base
image: roo-code-cloud-base
postgres:
container_name: roo-code-cloud-postgres
image: postgres:17.5
ports:
- "5432:5432"
volumes:
- ./.docker/data/postgres:/var/lib/postgresql/data
- ./.docker/scripts/postgres:/docker-entrypoint-initdb.d
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DATABASES=roo_code_development,roo_code_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d roo_code_development"]
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
clickhouse:
container_name: roo-code-cloud-clickhouse
image: clickhouse/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
volumes:
- ./.docker/data/clickhouse:/var/lib/clickhouse/
- ./.docker/logs/clickhouse:/var/log/clickhouse-server/
- ./.docker/scripts/clickhouse:/docker-entrypoint-initdb.d
environment:
- CLICKHOUSE_DB=default
- CLICKHOUSE_USER=default
- CLICKHOUSE_PASSWORD=password
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1",
]
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
redis:
container_name: roo-code-cloud-redis
image: redis:7-alpine
ports:
- "6380:6379"
volumes:
- ./.docker/data/redis:/data
command: redis-server --appendonly yes
roomote-dashboard:
container_name: roo-code-cloud-roomote-dashboard
build:
context: ../../
dockerfile: .docker/Dockerfile.dashboard
image: roo-code-cloud-roomote-dashboard
ports:
- "3002:3002"
environment:
- REDIS_URL=redis://redis:6379
- NODE_ENV=production
depends_on:
redis:
condition: service_started
roomote-api:
container_name: roo-code-cloud-roomote-api
build:
context: ../../
dockerfile: .docker/Dockerfile.api
image: roo-code-cloud-roomote-api
ports:
- "3001:3001"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/cloud_agents
- REDIS_URL=redis://redis:6379
- NODE_ENV=production
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
roomote-controller:
container_name: roo-code-cloud-roomote-controller
build:
context: ../../
dockerfile: .docker/Dockerfile.controller
args:
- GH_TOKEN=${GH_TOKEN}
image: roo-code-cloud-roomote-controller
env_file:
- .env
environment:
- HOST_EXECUTION_METHOD=docker
- DATABASE_URL=postgresql://postgres:password@db:5432/cloud_agents
- REDIS_URL=redis://redis:6379
- NODE_ENV=production
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /tmp/roomote/controller:/var/log/roomote
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
roomote-worker:
build:
context: ../../
dockerfile: .docker/Dockerfile.worker
args:
- GH_TOKEN=${GH_TOKEN}
image: roomote-worker
env_file:
- .env
environment:
- HOST_EXECUTION_METHOD=docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /tmp/roomote/worker:/var/log/roomote
stdin_open: true
tty: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
networks:
default:
name: roo-code-cloud_default
driver: bridge

View file

@ -10,7 +10,13 @@
"check-types": "turbo check-types --log-order grouped --output-logs new-only",
"test": "turbo test --log-order grouped --output-logs new-only",
"build": "turbo build --log-order grouped --output-logs new-only",
"clean": "turbo clean --log-order grouped --output-logs new-only && rimraf .turbo"
"clean": "turbo clean --log-order grouped --output-logs new-only && rimraf .turbo",
"services:up": "docker compose up postgres clickhouse redis roomote-dashboard -d --wait && pnpm db:push",
"services:down": "docker compose down postgres clickhouse redis roomote-dashboard",
"db:up": "docker compose up postgres clickhouse -d --wait && pnpm db:push",
"db:down": "docker compose down postgres clickhouse",
"db:push": "pnpm --filter @roo-code-cloud/web db:push --force && pnpm --filter @roo-code-cloud/web db:test:push --force",
"db:reset": "pnpm db:down && rimraf .docker/data .docker/logs && pnpm db:up"
},
"devDependencies": {
"@dotenvx/dotenvx": "^1.44.2",

View file

@ -0,0 +1,44 @@
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import turboPlugin from 'eslint-plugin-turbo';
import tseslint from 'typescript-eslint';
import onlyWarn from 'eslint-plugin-only-warn';
/**
* A shared ESLint configuration for the repository.
*
* @type {import("eslint").Linter.Config[]}
* */
export const config = [
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
plugins: {
turbo: turboPlugin,
},
rules: {
'turbo/no-undeclared-env-vars': 'off',
},
},
{
plugins: {
onlyWarn,
},
},
{
ignores: ['dist/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
];

View file

@ -0,0 +1,22 @@
import pluginNext from '@next/eslint-plugin-next';
import { reactConfig } from './react.js';
/**
* @type {import("eslint").Linter.Config[]}
*/
export const nextJsConfig = [
...reactConfig,
{
ignores: ['.next/**'],
},
{
plugins: {
'@next/next': pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs['core-web-vitals'].rules,
},
},
];

View file

@ -1,4 +1,22 @@
{
"name": "@roo-code-cloud/config-eslint",
"private": true
"private": true,
"type": "module",
"exports": {
"./base": "./base.js",
"./react": "./react.js",
"./next-js": "./next.js"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@next/eslint-plugin-next": "^15.2.1",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.4.4",
"globals": "^16.0.0",
"typescript-eslint": "^8.26.0"
}
}

38
packages/config-eslint/react.js vendored Normal file
View file

@ -0,0 +1,38 @@
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import typescriptEslint from 'typescript-eslint';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginReact from 'eslint-plugin-react';
import globals from 'globals';
import { config } from './base.js';
/**
* @type {import("eslint").Linter.Config[]}
*/
export const reactConfig = [
...config,
js.configs.recommended,
eslintConfigPrettier,
...typescriptEslint.configs.recommended,
{
...pluginReact.configs.flat.recommended,
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
},
},
},
{
plugins: {
'react-hooks': pluginReactHooks,
},
settings: { react: { version: 'detect' } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
'react/react-in-jsx-scope': 'off',
},
},
];

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"esModuleInterop": true,
"target": "ES2022",
"lib": ["ES2022", "ESNext.Disposable", "DOM"],
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"useUnknownInCatchVariables": false
}
}

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
}
}

View file

@ -1,4 +1,7 @@
{
"name": "@roo-code-cloud/config-typescript",
"private": true
"private": true,
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist",
"module": "esnext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": false,
"useUnknownInCatchVariables": false
}
}

View file

@ -0,0 +1,4 @@
import { config } from "@roo-code-cloud/config-eslint/base"
/** @type {import("eslint").Linter.Config} */
export default [...config]

22
packages/ipc/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@roo-code-cloud/ipc",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"lint": "eslint src --ext=ts --max-warnings=0",
"check-types": "tsc --noEmit",
"clean": "rimraf .turbo"
},
"dependencies": {
"@roo-code/types": "^1.26.0",
"node-ipc": "^12.0.0"
},
"devDependencies": {
"@roo-code-cloud/config-eslint": "workspace:^",
"@roo-code-cloud/config-typescript": "workspace:^",
"@types/node": "20.x",
"@types/node-ipc": "^9.2.3",
"vitest": "^3.2.3"
}
}

View file

@ -0,0 +1,2 @@
export * from './ipc-client.js';
export * from './ipc-server.js';

View file

@ -0,0 +1,129 @@
import EventEmitter from 'node:events';
import * as crypto from 'node:crypto';
import ipc from 'node-ipc';
import {
type TaskCommand,
type IpcClientEvents,
type IpcMessage,
IpcOrigin,
IpcMessageType,
ipcMessageSchema,
} from '@roo-code/types';
export class IpcClient extends EventEmitter<IpcClientEvents> {
private readonly _socketPath: string;
private readonly _id: string;
private readonly _log: (...args: unknown[]) => void;
private _isConnected = false;
private _clientId?: string;
constructor(socketPath: string, log = console.log) {
super();
this._socketPath = socketPath;
this._id = `roo-code-evals-${crypto.randomBytes(6).toString('hex')}`;
this._log = log;
ipc.config.silent = true;
ipc.connectTo(this._id, this.socketPath, () => {
ipc.of[this._id]?.on('connect', () => this.onConnect());
ipc.of[this._id]?.on('disconnect', () => this.onDisconnect());
ipc.of[this._id]?.on('message', (data) => this.onMessage(data));
});
}
private onConnect() {
if (this._isConnected) {
return;
}
this.log('[client#onConnect]');
this._isConnected = true;
this.emit(IpcMessageType.Connect);
}
private onDisconnect() {
if (!this._isConnected) {
return;
}
this.log('[client#onDisconnect]');
this._isConnected = false;
this.emit(IpcMessageType.Disconnect);
}
private onMessage(data: unknown) {
if (typeof data !== 'object') {
this._log('[client#onMessage] invalid data', data);
return;
}
const result = ipcMessageSchema.safeParse(data);
if (!result.success) {
this.log('[client#onMessage] invalid payload', result.error, data);
return;
}
const payload = result.data;
if (payload.origin === IpcOrigin.Server) {
switch (payload.type) {
case IpcMessageType.Ack:
this._clientId = payload.data.clientId;
this.emit(IpcMessageType.Ack, payload.data);
break;
case IpcMessageType.TaskEvent:
this.emit(IpcMessageType.TaskEvent, payload.data);
break;
}
}
}
private log(...args: unknown[]) {
this._log(...args);
}
public sendCommand(command: TaskCommand) {
const message: IpcMessage = {
type: IpcMessageType.TaskCommand,
origin: IpcOrigin.Client,
clientId: this._clientId!,
data: command,
};
this.sendMessage(message);
}
public sendMessage(message: IpcMessage) {
ipc.of[this._id]?.emit('message', message);
}
public disconnect() {
try {
ipc.disconnect(this._id);
// @TODO: Should we set _disconnect here?
} catch (error) {
this.log('[client#disconnect] error disconnecting', error);
}
}
public get socketPath() {
return this._socketPath;
}
public get clientId() {
return this._clientId;
}
public get isConnected() {
return this._isConnected;
}
public get isReady() {
return this._isConnected && this._clientId !== undefined;
}
}

View file

@ -0,0 +1,149 @@
import EventEmitter from 'node:events';
import { Socket } from 'node:net';
import * as crypto from 'node:crypto';
import ipc from 'node-ipc';
import {
type IpcServerEvents,
type RooCodeIpcServer,
IpcOrigin,
IpcMessageType,
type IpcMessage,
ipcMessageSchema,
} from '@roo-code/types';
export class IpcServer
extends EventEmitter<IpcServerEvents>
implements RooCodeIpcServer
{
private readonly _socketPath: string;
private readonly _log: (...args: unknown[]) => void;
private readonly _clients: Map<string, Socket>;
private _isListening = false;
constructor(socketPath: string, log = console.log) {
super();
this._socketPath = socketPath;
this._log = log;
this._clients = new Map();
}
public listen() {
this._isListening = true;
ipc.config.silent = true;
ipc.serve(this.socketPath, () => {
ipc.server.on('connect', (socket) => this.onConnect(socket));
ipc.server.on('socket.disconnected', (socket) =>
this.onDisconnect(socket),
);
ipc.server.on('message', (data) => this.onMessage(data));
});
ipc.server.start();
}
private onConnect(socket: Socket) {
const clientId = crypto.randomBytes(6).toString('hex');
this._clients.set(clientId, socket);
this.log(
`[server#onConnect] clientId = ${clientId}, # clients = ${this._clients.size}`,
);
this.send(socket, {
type: IpcMessageType.Ack,
origin: IpcOrigin.Server,
data: { clientId, pid: process.pid, ppid: process.ppid },
});
this.emit(IpcMessageType.Connect, clientId);
}
private onDisconnect(destroyedSocket: Socket) {
let disconnectedClientId: string | undefined;
for (const [clientId, socket] of this._clients.entries()) {
if (socket === destroyedSocket) {
disconnectedClientId = clientId;
this._clients.delete(clientId);
break;
}
}
this.log(
`[server#socket.disconnected] clientId = ${disconnectedClientId}, # clients = ${this._clients.size}`,
);
if (disconnectedClientId) {
this.emit(IpcMessageType.Disconnect, disconnectedClientId);
}
}
private onMessage(data: unknown) {
if (typeof data !== 'object') {
this.log('[server#onMessage] invalid data', data);
return;
}
const result = ipcMessageSchema.safeParse(data);
if (!result.success) {
this.log(
'[server#onMessage] invalid payload',
result.error.format(),
data,
);
return;
}
const payload = result.data;
if (payload.origin === IpcOrigin.Client) {
switch (payload.type) {
case IpcMessageType.TaskCommand:
this.emit(IpcMessageType.TaskCommand, payload.clientId, payload.data);
break;
default:
this.log(
`[server#onMessage] unhandled payload: ${JSON.stringify(payload)}`,
);
break;
}
}
}
private log(...args: unknown[]) {
this._log(...args);
}
public broadcast(message: IpcMessage) {
// this.log("[server#broadcast] message =", message)
ipc.server.broadcast('message', message);
}
public send(client: string | Socket, message: IpcMessage) {
// this.log("[server#send] message =", message)
if (typeof client === 'string') {
const socket = this._clients.get(client);
if (socket) {
ipc.server.emit(socket, 'message', message);
}
} else {
ipc.server.emit(client, 'message', message);
}
}
public get socketPath() {
return this._socketPath;
}
public get isListening() {
return this._isListening;
}
}

View file

@ -0,0 +1,8 @@
{
"extends": "@roo-code-cloud/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src"],
"exclude": ["node_modules"]
}

2171
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff