mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Add roomote to the monorepo (#109)
This commit is contained in:
parent
1ab782244b
commit
e8e7274393
90 changed files with 6181 additions and 592 deletions
27
.docker/Dockerfile.api
Normal file
27
.docker/Dockerfile.api
Normal 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
31
.docker/Dockerfile.base
Normal 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/*
|
||||
25
.docker/Dockerfile.controller
Normal file
25
.docker/Dockerfile.controller
Normal 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"]
|
||||
25
.docker/Dockerfile.dashboard
Normal file
25
.docker/Dockerfile.dashboard
Normal 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
62
.docker/Dockerfile.worker
Normal 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
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -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
|
||||
|
|
|
|||
9
apps/roomote/.env.example
Normal file
9
apps/roomote/.env.example
Normal 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
3
apps/roomote/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# next.js
|
||||
/.next
|
||||
/out
|
||||
10
apps/roomote/drizzle.config.ts
Normal file
10
apps/roomote/drizzle.config.ts
Normal 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!,
|
||||
},
|
||||
});
|
||||
21
apps/roomote/drizzle/0000_cuddly_luke_cage.sql
Normal file
21
apps/roomote/drizzle/0000_cuddly_luke_cage.sql
Normal 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;
|
||||
1
apps/roomote/drizzle/0001_fluffy_sasquatch.sql
Normal file
1
apps/roomote/drizzle/0001_fluffy_sasquatch.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "cloud_jobs" ADD COLUMN "slack_thread_ts" text;
|
||||
1
apps/roomote/drizzle/0002_brief_sentry.sql
Normal file
1
apps/roomote/drizzle/0002_brief_sentry.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE "cloud_tasks" CASCADE;
|
||||
164
apps/roomote/drizzle/meta/0000_snapshot.json
Normal file
164
apps/roomote/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
170
apps/roomote/drizzle/meta/0001_snapshot.json
Normal file
170
apps/roomote/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
105
apps/roomote/drizzle/meta/0002_snapshot.json
Normal file
105
apps/roomote/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
27
apps/roomote/drizzle/meta/_journal.json
Normal file
27
apps/roomote/drizzle/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
4
apps/roomote/eslint.config.mjs
Normal file
4
apps/roomote/eslint.config.mjs
Normal 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
5
apps/roomote/next-env.d.ts
vendored
Normal 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.
|
||||
7
apps/roomote/next.config.ts
Normal file
7
apps/roomote/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ['postgres', 'ioredis', 'bullmq'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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
38
apps/roomote/scripts/build.sh
Executable 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!"
|
||||
24
apps/roomote/scripts/dashboard.ts
Normal file
24
apps/roomote/scripts/dashboard.ts
Normal 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`),
|
||||
);
|
||||
74
apps/roomote/scripts/enqueue-github-issue-job.sh
Executable file
74
apps/roomote/scripts/enqueue-github-issue-job.sh
Executable 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"
|
||||
36
apps/roomote/src/app/api/health/route.ts
Normal file
36
apps/roomote/src/app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
48
apps/roomote/src/app/api/jobs/[id]/route.ts
Normal file
48
apps/roomote/src/app/api/jobs/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
44
apps/roomote/src/app/api/jobs/route.ts
Normal file
44
apps/roomote/src/app/api/jobs/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
376
apps/roomote/src/app/api/webhooks/github/__tests__/route.test.ts
Normal file
376
apps/roomote/src/app/api/webhooks/github/__tests__/route.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { verifySignature } from './utils';
|
||||
export { handleIssueEvent } from './issueHandler';
|
||||
export { handlePullRequestEvent } from './pullRequestHandler';
|
||||
export { handleIssueCommentEvent } from './issueCommentHandler';
|
||||
export { handlePullRequestReviewCommentEvent } from './pullRequestReviewCommentHandler';
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
40
apps/roomote/src/app/api/webhooks/github/handlers/utils.ts
Normal file
40
apps/roomote/src/app/api/webhooks/github/handlers/utils.ts
Normal 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 };
|
||||
}
|
||||
55
apps/roomote/src/app/api/webhooks/github/route.ts
Normal file
55
apps/roomote/src/app/api/webhooks/github/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
18
apps/roomote/src/app/layout.tsx
Normal file
18
apps/roomote/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/roomote/src/app/page.tsx
Normal file
3
apps/roomote/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <div>Hello, World!</div>;
|
||||
}
|
||||
11
apps/roomote/src/db/index.ts
Normal file
11
apps/roomote/src/db/index.ts
Normal 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';
|
||||
34
apps/roomote/src/db/schema.ts
Normal file
34
apps/roomote/src/db/schema.ts
Normal 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,
|
||||
};
|
||||
95
apps/roomote/src/lib/__tests__/controller.test.ts
Normal file
95
apps/roomote/src/lib/__tests__/controller.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
159
apps/roomote/src/lib/controller.ts
Normal file
159
apps/roomote/src/lib/controller.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
2
apps/roomote/src/lib/index.ts
Normal file
2
apps/roomote/src/lib/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { redis } from './redis';
|
||||
export { enqueue } from './queue';
|
||||
133
apps/roomote/src/lib/job.ts
Normal file
133
apps/roomote/src/lib/job.ts
Normal 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));
|
||||
}
|
||||
59
apps/roomote/src/lib/jobs/fixGitHubIssue.ts
Normal file
59
apps/roomote/src/lib/jobs/fixGitHubIssue.ts
Normal 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 };
|
||||
}
|
||||
71
apps/roomote/src/lib/jobs/processIssueComment.ts
Normal file
71
apps/roomote/src/lib/jobs/processIssueComment.ts
Normal 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 };
|
||||
}
|
||||
85
apps/roomote/src/lib/jobs/processPullRequestComment.ts
Normal file
85
apps/roomote/src/lib/jobs/processPullRequestComment.ts
Normal 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 };
|
||||
}
|
||||
86
apps/roomote/src/lib/logger.ts
Normal file
86
apps/roomote/src/lib/logger.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/roomote/src/lib/queue.ts
Normal file
23
apps/roomote/src/lib/queue.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
8
apps/roomote/src/lib/redis.ts
Normal file
8
apps/roomote/src/lib/redis.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import IORedis from 'ioredis';
|
||||
|
||||
export const redis = new IORedis(
|
||||
process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
{
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
);
|
||||
334
apps/roomote/src/lib/runTask.ts
Normal file
334
apps/roomote/src/lib/runTask.ts
Normal 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();
|
||||
};
|
||||
159
apps/roomote/src/lib/slack.ts
Normal file
159
apps/roomote/src/lib/slack.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
37
apps/roomote/src/lib/utils.ts
Normal file
37
apps/roomote/src/lib/utils.ts
Normal 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');
|
||||
};
|
||||
72
apps/roomote/src/lib/worker.ts
Normal file
72
apps/roomote/src/lib/worker.ts
Normal 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();
|
||||
61
apps/roomote/src/types/index.ts
Normal file
61
apps/roomote/src/types/index.ts
Normal 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>;
|
||||
};
|
||||
15
apps/roomote/tsconfig.json
Normal file
15
apps/roomote/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
4
apps/web/.gitignore
vendored
|
|
@ -1,7 +1,3 @@
|
|||
# next.js
|
||||
/.next
|
||||
/out
|
||||
|
||||
# docker
|
||||
.docker/*
|
||||
!.docker/scripts
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
41
apps/web/vitest.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
38
apps/web/vitest.setup.server.ts
Normal file
38
apps/web/vitest.setup.server.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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
148
docker-compose.yml
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
44
packages/config-eslint/base.js
Normal file
44
packages/config-eslint/base.js
Normal 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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
22
packages/config-eslint/next.js
Normal file
22
packages/config-eslint/next.js
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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
38
packages/config-eslint/react.js
vendored
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
19
packages/config-typescript/base.json
Normal file
19
packages/config-typescript/base.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
packages/config-typescript/cjs.json
Normal file
14
packages/config-typescript/cjs.json
Normal 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
|
||||
}
|
||||
}
|
||||
12
packages/config-typescript/nextjs.json
Normal file
12
packages/config-typescript/nextjs.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"name": "@roo-code-cloud/config-typescript",
|
||||
"private": true
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
packages/config-typescript/vscode-library.json
Normal file
12
packages/config-typescript/vscode-library.json
Normal 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
|
||||
}
|
||||
}
|
||||
4
packages/ipc/eslint.config.mjs
Normal file
4
packages/ipc/eslint.config.mjs
Normal 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
22
packages/ipc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
packages/ipc/src/index.ts
Normal file
2
packages/ipc/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ipc-client.js';
|
||||
export * from './ipc-server.js';
|
||||
129
packages/ipc/src/ipc-client.ts
Normal file
129
packages/ipc/src/ipc-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
149
packages/ipc/src/ipc-server.ts
Normal file
149
packages/ipc/src/ipc-server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
packages/ipc/tsconfig.json
Normal file
8
packages/ipc/tsconfig.json
Normal 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
2171
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue