From 17ce73186769907569d74c949640183973e0906f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 7 Jul 2025 13:52:18 -0400 Subject: [PATCH] Prototype of a Roomote tab (#147) Co-authored-by: cte Co-authored-by: Roomote Co-authored-by: John Richmond <5629+jr@users.noreply.github.com> --- .gitignore | 2 +- .roo/rules/rules.md | 1 - apps/roomote/package.json | 3 - .../github/handlers/__tests__/utils.test.ts | 56 +- .../app/api/webhooks/github/handlers/utils.ts | 27 +- apps/roomote/src/lib/cli.ts | 298 ----- apps/roomote/src/lib/job.ts | 126 +- apps/roomote/src/lib/jobs/fixGitHubIssue.ts | 26 +- apps/roomote/src/lib/jobs/index.ts | 1 + .../src/lib/jobs/processGeneralTask.ts | 33 + .../src/lib/jobs/processIssueComment.ts | 24 +- .../src/lib/jobs/processPullRequestComment.ts | 25 +- .../src/lib/jobs/processSlackMention.ts | 25 +- apps/roomote/src/lib/promptConstants.ts | 6 +- apps/roomote/src/lib/runTask.ts | 15 +- apps/roomote/src/lib/slack.ts | 15 + apps/web/.env | 2 + apps/web/.env.production | 2 + apps/web/package.json | 1 + apps/web/src/actions/auth.ts | 28 +- apps/web/src/actions/roomote.ts | 238 ++++ .../roomote/ConfigureTasks.tsx | 155 +++ .../(authenticated)/roomote/CreateTask.tsx | 141 +++ .../src/app/(authenticated)/roomote/Jobs.tsx | 267 ++++ .../roomote/components/GeneralTaskFields.tsx | 65 + .../components/GitHubIssueCommentFields.tsx | 150 +++ .../components/GitHubIssueFixFields.tsx | 89 ++ .../components/GitHubPRCommentFields.tsx | 215 ++++ .../roomote/components/index.ts | 4 + .../app/(authenticated)/roomote/constants.ts | 61 + .../src/app/(authenticated)/roomote/page.tsx | 28 + .../src/app/(authenticated)/roomote/types.ts | 19 + .../src/app/(authenticated)/roomote/utils.ts | 58 + .../src/app/(authenticated)/usage/Usage.tsx | 45 +- .../src/app/(authenticated)/usage/page.tsx | 8 +- .../__tests__/ProviderForm.test.tsx | 7 + apps/web/src/components/layout/NavbarMenu.tsx | 6 + apps/web/src/components/ui/dialog.tsx | 1 + apps/web/src/components/ui/index.ts | 1 + apps/web/src/components/ui/select.tsx | 185 +++ apps/web/src/hooks/useRoomotes.ts | 23 + apps/web/src/lib/__tests__/roomotes.test.ts | 159 +++ apps/web/src/lib/roomotes.ts | 43 + apps/web/src/types/react-query.ts | 3 + fly.roomote-worker.toml | 2 +- packages/db/drizzle/0003_good_sauron.sql | 5 + packages/db/drizzle/meta/0003_snapshot.json | 1127 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 44 +- packages/db/src/server.ts | 3 - packages/db/src/types.ts | 145 ++- pnpm-lock.yaml | 65 +- 52 files changed, 3507 insertions(+), 578 deletions(-) delete mode 100644 apps/roomote/src/lib/cli.ts create mode 100644 apps/roomote/src/lib/jobs/processGeneralTask.ts create mode 100644 apps/web/src/actions/roomote.ts create mode 100644 apps/web/src/app/(authenticated)/roomote/ConfigureTasks.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/CreateTask.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/Jobs.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/components/GeneralTaskFields.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/components/GitHubIssueCommentFields.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/components/GitHubIssueFixFields.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/components/GitHubPRCommentFields.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/components/index.ts create mode 100644 apps/web/src/app/(authenticated)/roomote/constants.ts create mode 100644 apps/web/src/app/(authenticated)/roomote/page.tsx create mode 100644 apps/web/src/app/(authenticated)/roomote/types.ts create mode 100644 apps/web/src/app/(authenticated)/roomote/utils.ts create mode 100644 apps/web/src/components/ui/select.tsx create mode 100644 apps/web/src/hooks/useRoomotes.ts create mode 100644 apps/web/src/lib/__tests__/roomotes.test.ts create mode 100644 apps/web/src/lib/roomotes.ts create mode 100644 packages/db/drizzle/0003_good_sauron.sql create mode 100644 packages/db/drizzle/meta/0003_snapshot.json diff --git a/.gitignore b/.gitignore index 6a114a447a..32d4f05f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ Thumbs.db *.log # project mcp config -.roo/mcp.json \ No newline at end of file +.roo/mcp.json diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 91f19c1163..9c7940ca2e 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -3,7 +3,6 @@ ## Testing & Formatting - **Test**: `pnpm test` (runs all tests via Turbo) -- **Test single file**: `pnpm test path/to/file.test.ts` - **Lint**: `pnpm lint` (ESLint with TypeScript support) - **Type check**: `pnpm check-types` - **Format**: Files are auto-formatted on commit via lint-staged diff --git a/apps/roomote/package.json b/apps/roomote/package.json index 3610939b75..6f86f571b5 100644 --- a/apps/roomote/package.json +++ b/apps/roomote/package.json @@ -13,8 +13,6 @@ "controller:production": "dotenvx run -f ../../.env.production -- tsx src/lib/controller.ts", "worker": "dotenvx run -f ../../.env.development -- tsx src/lib/worker.ts", "worker:production": "dotenvx run -f ../../.env.production -- tsx src/lib/worker.ts", - "cli": "dotenvx run -f ../../.env.development -- tsx src/lib/cli.ts", - "cli:production": "dotenvx run -f ../../.env.production -- tsx src/lib/cli.ts", "clean": "rimraf .next .turbo" }, "dependencies": { @@ -24,7 +22,6 @@ "@roo-code-cloud/job-auth": "workspace:^", "@roo-code/types": "^1.30.0", "bullmq": "^5.37.0", - "cmd-ts": "^0.13.0", "drizzle-orm": "^0.44.2", "execa": "^9.6.0", "ioredis": "^5.6.1", diff --git a/apps/roomote/src/app/api/webhooks/github/handlers/__tests__/utils.test.ts b/apps/roomote/src/app/api/webhooks/github/handlers/__tests__/utils.test.ts index 356027836e..778e6e43ed 100644 --- a/apps/roomote/src/app/api/webhooks/github/handlers/__tests__/utils.test.ts +++ b/apps/roomote/src/app/api/webhooks/github/handlers/__tests__/utils.test.ts @@ -87,6 +87,7 @@ describe('GitHub Webhook Utils', () => { const mockJob = { id: 123 }; const mockEnqueuedJob = { id: 'enqueued-123' }; const mockCloudJobs = {}; + const testOrgId = 'test-org-123'; beforeEach(() => { mockDb.insert.mockReturnValue({ @@ -107,13 +108,14 @@ describe('GitHub Webhook Utils', () => { body: 'Test body', }; - const result = await createAndEnqueueJob(type, payload); + const result = await createAndEnqueueJob(type, payload, testOrgId); expect(mockDb.insert).toHaveBeenCalledWith(mockCloudJobs); expect(mockEnqueue).toHaveBeenCalledWith({ jobId: mockJob.id, type, payload, + orgId: testOrgId, }); expect(result).toEqual({ jobId: mockJob.id, @@ -136,9 +138,9 @@ describe('GitHub Webhook Utils', () => { body: 'Test', }; - await expect(createAndEnqueueJob(type, payload)).rejects.toThrow( - 'Failed to create `cloudJobs` record.', - ); + await expect( + createAndEnqueueJob(type, payload, testOrgId), + ).rejects.toThrow('Failed to create `cloudJobs` record.'); }); it('should throw error when enqueue fails to return job ID', async () => { @@ -152,9 +154,9 @@ describe('GitHub Webhook Utils', () => { body: 'Test', }; - await expect(createAndEnqueueJob(type, payload)).rejects.toThrow( - 'Failed to get enqueued job ID.', - ); + await expect( + createAndEnqueueJob(type, payload, testOrgId), + ).rejects.toThrow('Failed to get enqueued job ID.'); }); it('should throw error when enqueue returns undefined', async () => { @@ -168,9 +170,9 @@ describe('GitHub Webhook Utils', () => { body: 'Test', }; - await expect(createAndEnqueueJob(type, payload)).rejects.toThrow( - 'Failed to get enqueued job ID.', - ); + await expect( + createAndEnqueueJob(type, payload, testOrgId), + ).rejects.toThrow('Failed to get enqueued job ID.'); }); it('should handle different job types', async () => { @@ -189,12 +191,13 @@ describe('GitHub Webhook Utils', () => { commentUrl: 'https://github.com/test/repo/issues/456#issuecomment-789', }; - const result = await createAndEnqueueJob(type, payload); + const result = await createAndEnqueueJob(type, payload, testOrgId); expect(mockEnqueue).toHaveBeenCalledWith({ jobId: mockJob.id, type, payload, + orgId: testOrgId, }); expect(result).toEqual({ jobId: mockJob.id, @@ -213,7 +216,7 @@ describe('GitHub Webhook Utils', () => { body: 'Test', }; - await createAndEnqueueJob(type, payload); + await createAndEnqueueJob(type, payload, testOrgId); expect(consoleSpy).toHaveBeenCalledWith( `🔗 Enqueued ${type} job (id: ${mockJob.id}) ->`, @@ -236,9 +239,9 @@ describe('GitHub Webhook Utils', () => { body: 'Test', }; - await expect(createAndEnqueueJob(type, payload)).rejects.toThrow( - 'Database connection failed', - ); + await expect( + createAndEnqueueJob(type, payload, testOrgId), + ).rejects.toThrow('Database connection failed'); }); it('should handle enqueue service errors', async () => { @@ -252,9 +255,9 @@ describe('GitHub Webhook Utils', () => { body: 'Test', }; - await expect(createAndEnqueueJob(type, payload)).rejects.toThrow( - 'Queue service unavailable', - ); + await expect( + createAndEnqueueJob(type, payload, testOrgId), + ).rejects.toThrow('Queue service unavailable'); }); }); @@ -362,6 +365,7 @@ describe('GitHub Webhook Utils', () => { // Test job creation in a realistic webhook scenario. const mockJob = { id: 456 }; const mockEnqueuedJob = { id: 'job-456' }; + const testOrgId = 'test-org-456'; mockDb.insert.mockReturnValue({ values: vi.fn().mockReturnValue({ @@ -371,12 +375,16 @@ describe('GitHub Webhook Utils', () => { 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', - }); + const result = await createAndEnqueueJob( + 'github.issue.fix', + { + repo: 'test/repo', + issue: 123, + title: 'Test issue', + body: 'Test issue body', + }, + testOrgId, + ); expect(result).toEqual({ jobId: mockJob.id, diff --git a/apps/roomote/src/app/api/webhooks/github/handlers/utils.ts b/apps/roomote/src/app/api/webhooks/github/handlers/utils.ts index cca7b84be3..7e2810670f 100644 --- a/apps/roomote/src/app/api/webhooks/github/handlers/utils.ts +++ b/apps/roomote/src/app/api/webhooks/github/handlers/utils.ts @@ -1,10 +1,12 @@ import { createHmac } from 'crypto'; +import { or, eq } from 'drizzle-orm'; import { type JobType, type JobPayload, db, cloudJobs, + orgs, } from '@roo-code-cloud/db/server'; import { enqueue } from '@/lib'; @@ -25,17 +27,38 @@ export function verifySignature( export async function createAndEnqueueJob( type: T, payload: JobPayload, + orgId?: string, ): Promise<{ jobId: number; enqueuedJobId: string }> { + // @TODO: Require `orgId` to be specified. + const organizationId = + orgId || + ( + await db + .select({ id: orgs.id }) + .from(orgs) + .where(or(eq(orgs.name, 'Roo Code'), eq(orgs.name, 'Roo Code / Dev'))) + .limit(1) + )[0]?.id; + + if (!organizationId) { + throw new Error('Organization ID is required for job creation.'); + } + const [job] = await db .insert(cloudJobs) - .values({ type, payload, status: 'pending' }) + .values({ type, payload, status: 'pending', orgId: organizationId }) .returning(); if (!job) { throw new Error('Failed to create `cloudJobs` record.'); } - const enqueuedJob = await enqueue({ jobId: job.id, type, payload }); + const enqueuedJob = await enqueue({ + jobId: job.id, + type, + payload, + orgId: organizationId, + }); console.log(`🔗 Enqueued ${type} job (id: ${job.id}) ->`, payload); if (!enqueuedJob.id) { diff --git a/apps/roomote/src/lib/cli.ts b/apps/roomote/src/lib/cli.ts deleted file mode 100644 index b06dea90d1..0000000000 --- a/apps/roomote/src/lib/cli.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { - command, - run, - string, - number, - option, - subcommands, - optional, -} from 'cmd-ts'; - -import type { JobPayload } from '@roo-code-cloud/db'; - -import { - fixGitHubIssue, - processIssueComment, - processPullRequestComment, -} from '@/lib/jobs'; -import { runTask } from '@/lib/runTask'; - -const fixIssueCommand = command({ - name: 'fix-issue', - description: 'Fix a GitHub issue', - args: { - repo: option({ - type: string, - long: 'repo', - short: 'r', - description: 'Repository name (e.g., owner/repo)', - }), - issue: option({ - type: number, - long: 'issue', - short: 'i', - description: 'Issue number', - }), - title: option({ - type: string, - long: 'title', - short: 't', - description: 'Issue title', - }), - body: option({ - type: string, - long: 'body', - short: 'b', - description: 'Issue body/description', - }), - labels: option({ - type: optional(string), - long: 'labels', - short: 'l', - description: 'Issue labels (comma-separated: -l "bug,enhancement")', - }), - }, - handler: async ({ repo, issue, title, body, labels }) => { - try { - console.log('🔧 Fixing GitHub issue...'); - - const payload: JobPayload<'github.issue.fix'> = { - repo, - issue, - title, - body, - labels: labels ? labels.split(',').map((l) => l.trim()) : [], - }; - - const result = await fixGitHubIssue(payload); - - console.log('✅ Issue fix completed successfully!', result); - process.exit(0); - } catch (error) { - console.error('❌ Error fixing issue:', error); - process.exit(1); - } - }, -}); - -const respondIssueCommentCommand = command({ - name: 'respond-issue-comment', - description: 'Respond to a GitHub issue comment', - args: { - repo: option({ - type: string, - long: 'repo', - short: 'r', - description: 'Repository name (e.g., owner/repo)', - }), - issueNumber: option({ - type: number, - long: 'issue-number', - description: 'Issue number', - }), - issueTitle: option({ - type: string, - long: 'issue-title', - description: 'Issue title', - }), - issueBody: option({ - type: string, - long: 'issue-body', - description: 'Issue body', - }), - commentId: option({ - type: number, - long: 'comment-id', - description: 'Comment ID', - }), - commentBody: option({ - type: string, - long: 'comment-body', - description: 'Comment body', - }), - commentAuthor: option({ - type: string, - long: 'comment-author', - description: 'Comment author', - }), - commentUrl: option({ - type: string, - long: 'comment-url', - description: 'Comment URL', - }), - }, - handler: async (args) => { - try { - console.log('💬 Responding to GitHub issue comment...'); - - const payload: JobPayload<'github.issue.comment.respond'> = { - repo: args.repo, - issueNumber: args.issueNumber, - issueTitle: args.issueTitle, - issueBody: args.issueBody, - commentId: args.commentId, - commentBody: args.commentBody, - commentAuthor: args.commentAuthor, - commentUrl: args.commentUrl, - }; - - const result = await processIssueComment(payload); - console.log('✅ Issue comment response completed successfully!', result); - process.exit(0); - } catch (error) { - console.error('❌ Error responding to issue comment:', error); - process.exit(1); - } - }, -}); - -const respondPrCommentCommand = command({ - name: 'respond-pr-comment', - description: 'Respond to a GitHub PR comment', - args: { - repo: option({ - type: string, - long: 'repo', - short: 'r', - description: 'Repository name (e.g., owner/repo)', - }), - prNumber: option({ - type: number, - long: 'pr-number', - description: 'PR number', - }), - prTitle: option({ - type: string, - long: 'pr-title', - description: 'PR title', - }), - prBody: option({ - type: string, - long: 'pr-body', - description: 'PR body', - }), - prBranch: option({ - type: string, - long: 'pr-branch', - description: 'PR branch', - }), - baseRef: option({ - type: string, - long: 'base-ref', - description: 'Base reference', - }), - commentId: option({ - type: number, - long: 'comment-id', - description: 'Comment ID', - }), - commentBody: option({ - type: string, - long: 'comment-body', - description: 'Comment body', - }), - commentAuthor: option({ - type: string, - long: 'comment-author', - description: 'Comment author', - }), - commentType: option({ - type: string, - long: 'comment-type', - description: 'Comment type (issue_comment or review_comment)', - }), - commentUrl: option({ - type: string, - long: 'comment-url', - description: 'Comment URL', - }), - }, - handler: async (args) => { - try { - console.log('💬 Responding to GitHub PR comment...'); - - if ( - args.commentType !== 'issue_comment' && - args.commentType !== 'review_comment' - ) { - throw new Error( - 'Comment type must be either "issue_comment" or "review_comment"', - ); - } - - const payload: JobPayload<'github.pr.comment.respond'> = { - repo: args.repo, - prNumber: args.prNumber, - prTitle: args.prTitle, - prBody: args.prBody, - prBranch: args.prBranch, - baseRef: args.baseRef, - commentId: args.commentId, - commentBody: args.commentBody, - commentAuthor: args.commentAuthor, - commentType: args.commentType as 'issue_comment' | 'review_comment', - commentUrl: args.commentUrl, - }; - - const result = await processPullRequestComment(payload); - console.log('✅ PR comment response completed successfully!', result); - process.exit(0); - } catch (error) { - console.error('❌ Error responding to PR comment:', error); - process.exit(1); - } - }, -}); - -const promptCommand = command({ - name: 'prompt', - description: 'Prompt', - args: { - text: option({ - type: string, - long: 'text', - description: 'Text to test', - }), - mode: option({ - type: string, - long: 'mode', - description: 'Mode to use: code, ask, architect', - }), - workspacePath: option({ - type: string, - long: 'workspace-path', - description: 'Workspace path', - }), - }, - handler: async ({ text, mode, workspacePath }) => { - if (!['code', 'ask', 'architect'].includes(mode)) { - throw new Error('Invalid mode'); - } - - await runTask({ - jobType: 'test.prompt', - jobPayload: { text }, - prompt: text, - notify: false, - workspacePath, - settings: { mode }, - }); - - process.exit(0); - }, -}); - -// Example: -// pnpm --filter @roo-code-cloud/roomote cli prompt --text "What time is it?" --mode ask --workspace-path ~/Documents/Roo-Code -const app = subcommands({ - name: 'roomote-cli', - description: 'Roomote CLI - Run jobs directly from the command line', - cmds: { - [fixIssueCommand.name]: fixIssueCommand, - [respondIssueCommentCommand.name]: respondIssueCommentCommand, - [respondPrCommentCommand.name]: respondPrCommentCommand, - [promptCommand.name]: promptCommand, - }, -}); - -run(app, process.argv.slice(2)); diff --git a/apps/roomote/src/lib/job.ts b/apps/roomote/src/lib/job.ts index 1fcd4f5757..ade512fb13 100644 --- a/apps/roomote/src/lib/job.ts +++ b/apps/roomote/src/lib/job.ts @@ -10,18 +10,20 @@ import { type UpdateCloudJob, db, cloudJobs, + orgSettings, } from '@roo-code-cloud/db/server'; import { fixGitHubIssue } from './jobs/fixGitHubIssue'; import { processPullRequestComment } from './jobs/processPullRequestComment'; import { processIssueComment } from './jobs/processIssueComment'; import { processSlackMention } from './jobs/processSlackMention'; +import { processGeneralTask } from './jobs/processGeneralTask'; import { SlackNotifier } from './slack'; const slack = new SlackNotifier(); export async function processJob({ - data: { type, payload, jobId }, + data: { type, payload, jobId, orgId }, ...job }: Job>) { console.log( @@ -29,63 +31,37 @@ export async function processJob({ ); try { + const onTaskStarted = createOnTaskStartedCallback(jobId); + const mode = await getConfiguredMode(orgId, type); + + console.log( + `[${job.name} | ${job.id}] Using mode '${mode}' for task type '${type}'`, + ); + let result: unknown; switch (type) { case 'github.issue.fix': result = await fixGitHubIssue( payload as JobPayload<'github.issue.fix'>, - { - onTaskStarted: ( - slackThreadTs: string | null | undefined, - _rooTaskId: string, - ) => - updateJobStatus( - jobId, - 'processing', - undefined, - undefined, - slackThreadTs, - ), - }, + { onTaskStarted }, + mode, ); break; case 'github.issue.comment.respond': result = await processIssueComment( payload as JobPayload<'github.issue.comment.respond'>, - { - onTaskStarted: ( - slackThreadTs: string | null | undefined, - _rooTaskId: string, - ) => - updateJobStatus( - jobId, - 'processing', - undefined, - undefined, - slackThreadTs, - ), - }, + { onTaskStarted }, + mode, ); break; case 'github.pr.comment.respond': result = await processPullRequestComment( payload as JobPayload<'github.pr.comment.respond'>, - { - onTaskStarted: ( - slackThreadTs: string | null | undefined, - _rooTaskId: string, - ) => - updateJobStatus( - jobId, - 'processing', - undefined, - undefined, - slackThreadTs, - ), - }, + { onTaskStarted }, + mode, ); break; @@ -94,17 +70,7 @@ export async function processJob({ const { channel, thread_ts } = jobPayload; result = await processSlackMention(jobPayload, { - onTaskStarted: ( - slackThreadTs: string | null | undefined, - _rooTaskId: string, - ) => - updateJobStatus( - jobId, - 'processing', - undefined, - undefined, - slackThreadTs, - ), + onTaskStarted, onTaskMessage: async (message: ClineMessage) => { console.log(`onTaskMessage (${channel}, ${thread_ts}) ->`, message); @@ -120,6 +86,13 @@ export async function processJob({ break; } + + case 'general.task': { + const jobPayload = payload as JobPayload<'general.task'>; + result = await processGeneralTask(jobPayload, { onTaskStarted }); + break; + } + default: throw new Error(`Unknown job type: ${type}`); } @@ -134,6 +107,11 @@ export async function processJob({ } } +function createOnTaskStartedCallback(jobId: number) { + return (slackThreadTs: string | null | undefined, _rooTaskId: string) => + updateJobStatus(jobId, 'processing', undefined, undefined, slackThreadTs); +} + async function updateJobStatus( jobId: number, status: JobStatus, @@ -163,3 +141,49 @@ async function updateJobStatus( await db.update(cloudJobs).set(values).where(eq(cloudJobs.id, jobId)); } + +const DEFAULT_MODE_MAPPINGS: Record = { + 'github.issue.fix': 'issue-fixer', + 'github.issue.comment.respond': 'ask', + 'github.pr.comment.respond': 'ask', + 'slack.app.mention': 'code', + 'general.task': 'code', +}; + +async function getConfiguredMode( + orgId: string, + taskType: JobType, +): Promise { + try { + const settings = await db + .select() + .from(orgSettings) + .where(eq(orgSettings.orgId, orgId)) + .limit(1); + + if (settings.length === 0) { + return DEFAULT_MODE_MAPPINGS[taskType]; + } + + const orgSetting = settings[0]; + + if (!orgSetting) { + return DEFAULT_MODE_MAPPINGS[taskType]; + } + + const cloudSettings = orgSetting.cloudSettings as Record; + + const roomoteModeMappings = cloudSettings?.roomoteModeMappings as + | Partial> + | undefined; + + if (roomoteModeMappings && roomoteModeMappings[taskType]) { + return roomoteModeMappings[taskType]; + } + + return DEFAULT_MODE_MAPPINGS[taskType]; + } catch (error) { + console.error('Error fetching organization settings:', error); + return DEFAULT_MODE_MAPPINGS[taskType]; + } +} diff --git a/apps/roomote/src/lib/jobs/fixGitHubIssue.ts b/apps/roomote/src/lib/jobs/fixGitHubIssue.ts index 21c0f02c6f..2795d9e53a 100644 --- a/apps/roomote/src/lib/jobs/fixGitHubIssue.ts +++ b/apps/roomote/src/lib/jobs/fixGitHubIssue.ts @@ -1,20 +1,16 @@ -import type { JobType, JobPayload } from '@roo-code-cloud/db'; +import type { JobPayload } from '@roo-code-cloud/db'; import { runTask, type RunTaskCallbacks } from '../runTask'; -import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants'; - -const jobType: JobType = 'github.issue.fix'; - -type FixGitHubIssueJobPayload = JobPayload<'github.issue.fix'>; +import { + CRITICAL_COMMAND_RESTRICTIONS, + MAIN_BRANCH_PROTECTION, +} from '../promptConstants'; export async function fixGitHubIssue( - jobPayload: FixGitHubIssueJobPayload, + jobPayload: JobPayload<'github.issue.fix'>, callbacks?: RunTaskCallbacks, -): Promise<{ - repo: string; - issue: number; - result: unknown; -}> { + mode?: string, +) { const prompt = ` Fix the following GitHub issue: @@ -29,13 +25,11 @@ ${MAIN_BRANCH_PROTECTION} const { repo, issue } = jobPayload; const result = await runTask({ - jobType, + jobType: 'github.issue.fix', jobPayload, prompt, callbacks, - settings: { - mode: 'issue-fixer', - }, + mode, }); return { repo, issue, result }; diff --git a/apps/roomote/src/lib/jobs/index.ts b/apps/roomote/src/lib/jobs/index.ts index 14609e0456..d05f4ba98a 100644 --- a/apps/roomote/src/lib/jobs/index.ts +++ b/apps/roomote/src/lib/jobs/index.ts @@ -2,3 +2,4 @@ export { fixGitHubIssue } from './fixGitHubIssue'; export { processIssueComment } from './processIssueComment'; export { processPullRequestComment } from './processPullRequestComment'; export { processSlackMention } from './processSlackMention'; +export { processGeneralTask } from './processGeneralTask'; diff --git a/apps/roomote/src/lib/jobs/processGeneralTask.ts b/apps/roomote/src/lib/jobs/processGeneralTask.ts new file mode 100644 index 0000000000..7e422df448 --- /dev/null +++ b/apps/roomote/src/lib/jobs/processGeneralTask.ts @@ -0,0 +1,33 @@ +import type { JobPayload } from '@roo-code-cloud/db'; + +import { runTask, type RunTaskCallbacks } from '../runTask'; + +export async function processGeneralTask( + jobPayload: JobPayload<'general.task'>, + callbacks?: RunTaskCallbacks, + mode?: string, +) { + // Add your workspace root to .env.local to override the default + // workspace root that our containers use. + const workspaceRoot = process.env.WORKSPACE_ROOT || '/roo/repos'; + const workspacePath = `${workspaceRoot}/${jobPayload.repo.split('/')[1]}`; + + const prompt = ` +Repository: ${jobPayload.repo} +Task: ${jobPayload.description} + +Please complete this task and create a pull request with your changes when finished. +`; + + const result = await runTask({ + jobType: 'general.task', + jobPayload, + prompt, + callbacks, + notify: false, + workspacePath, + mode, + }); + + return { result }; +} diff --git a/apps/roomote/src/lib/jobs/processIssueComment.ts b/apps/roomote/src/lib/jobs/processIssueComment.ts index 70b8704d5c..3d124d9db1 100644 --- a/apps/roomote/src/lib/jobs/processIssueComment.ts +++ b/apps/roomote/src/lib/jobs/processIssueComment.ts @@ -1,21 +1,16 @@ -import type { JobType, JobPayload } from '@roo-code-cloud/db'; +import type { JobPayload } from '@roo-code-cloud/db'; import { runTask, type RunTaskCallbacks } from '../runTask'; -import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants'; - -const jobType: JobType = 'github.issue.comment.respond'; - -type ProcessIssueCommentJobPayload = JobPayload<'github.issue.comment.respond'>; +import { + CRITICAL_COMMAND_RESTRICTIONS, + MAIN_BRANCH_PROTECTION, +} from '../promptConstants'; export async function processIssueComment( - jobPayload: ProcessIssueCommentJobPayload, + jobPayload: JobPayload<'github.issue.comment.respond'>, callbacks?: RunTaskCallbacks, -): Promise<{ - repo: string; - issueNumber: number; - commentId: number; - result: unknown; -}> { + mode?: string, +) { const prompt = ` Respond to the following GitHub Issue comment: @@ -56,10 +51,11 @@ gh api repos/${jobPayload.repo}/issues/${jobPayload.issueNumber}/comments --meth const { repo, issueNumber, commentId } = jobPayload; const result = await runTask({ - jobType, + jobType: 'github.issue.comment.respond', jobPayload, prompt, callbacks, + mode, }); return { repo, issueNumber, commentId, result }; diff --git a/apps/roomote/src/lib/jobs/processPullRequestComment.ts b/apps/roomote/src/lib/jobs/processPullRequestComment.ts index 27a48584a3..c5edf1130e 100644 --- a/apps/roomote/src/lib/jobs/processPullRequestComment.ts +++ b/apps/roomote/src/lib/jobs/processPullRequestComment.ts @@ -1,22 +1,16 @@ -import type { JobType, JobPayload } from '@roo-code-cloud/db'; +import type { JobPayload } from '@roo-code-cloud/db'; import { runTask, type RunTaskCallbacks } from '../runTask'; -import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants'; - -const jobType: JobType = 'github.pr.comment.respond'; - -type ProcessPullRequestCommentJobPayload = - JobPayload<'github.pr.comment.respond'>; +import { + CRITICAL_COMMAND_RESTRICTIONS, + MAIN_BRANCH_PROTECTION, +} from '../promptConstants'; export async function processPullRequestComment( - jobPayload: ProcessPullRequestCommentJobPayload, + jobPayload: JobPayload<'github.pr.comment.respond'>, callbacks?: RunTaskCallbacks, -): Promise<{ - repo: string; - prNumber: number; - commentId: number; - result: unknown; -}> { + mode?: string, +) { const prompt = ` Process the following GitHub Pull Request comment: @@ -70,10 +64,11 @@ Do not create a new pull request - work directly on the existing PR branch. const { repo, prNumber, commentId } = jobPayload; const result = await runTask({ - jobType, + jobType: 'github.pr.comment.respond', jobPayload, prompt, callbacks, + mode, }); return { repo, prNumber, commentId, result }; diff --git a/apps/roomote/src/lib/jobs/processSlackMention.ts b/apps/roomote/src/lib/jobs/processSlackMention.ts index b232afd4cf..4f5a38c599 100644 --- a/apps/roomote/src/lib/jobs/processSlackMention.ts +++ b/apps/roomote/src/lib/jobs/processSlackMention.ts @@ -1,23 +1,19 @@ -import type { JobType, JobPayload } from '@roo-code-cloud/db'; +import type { JobPayload } from '@roo-code-cloud/db'; import { runTask, type RunTaskCallbacks } from '../runTask'; -import { CRITICAL_COMMAND_RESTRICTIONS, GIT_WORKFLOW_INSTRUCTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants'; - -const jobType: JobType = 'slack.app.mention'; - -type ProcessSlackMentionJobPayload = JobPayload<'slack.app.mention'>; +import { + CRITICAL_COMMAND_RESTRICTIONS, + GIT_WORKFLOW_INSTRUCTIONS, + MAIN_BRANCH_PROTECTION, +} from '../promptConstants'; export async function processSlackMention( - jobPayload: ProcessSlackMentionJobPayload, + jobPayload: JobPayload<'slack.app.mention'>, callbacks?: RunTaskCallbacks, -): Promise<{ - channel: string; - user: string; - result: unknown; -}> { + mode?: string, +) { const { text: originalPrompt, channel, user } = jobPayload; - // Create a structured prompt following the same pattern as other roomote triggers const prompt = ` Process the following Slack mention request: @@ -50,12 +46,13 @@ ${GIT_WORKFLOW_INSTRUCTIONS} : `${workspaceRoot}/${jobPayload.workspace}`; const result = await runTask({ - jobType, + jobType: 'slack.app.mention', jobPayload, prompt, callbacks, notify: false, workspacePath, + mode, }); return { channel, user, result }; diff --git a/apps/roomote/src/lib/promptConstants.ts b/apps/roomote/src/lib/promptConstants.ts index 3baa6485be..b6e90febaa 100644 --- a/apps/roomote/src/lib/promptConstants.ts +++ b/apps/roomote/src/lib/promptConstants.ts @@ -1,7 +1,3 @@ -/** - * Shared constants for roomote job prompts - */ - export const CRITICAL_COMMAND_RESTRICTIONS = ` CRITICAL COMMAND RESTRICTIONS: - NEVER execute long-running commands like starting servers (npm run dev, npm start, python -m http.server, etc.) @@ -21,4 +17,4 @@ This ensures all changes are properly tracked and can be reviewed before merging export const MAIN_BRANCH_PROTECTION = ` NEVER commit directly to the main branch. Always create a feature branch for your changes. -`.trim(); \ No newline at end of file +`.trim(); diff --git a/apps/roomote/src/lib/runTask.ts b/apps/roomote/src/lib/runTask.ts index 80cc0de258..9f8aa601d6 100644 --- a/apps/roomote/src/lib/runTask.ts +++ b/apps/roomote/src/lib/runTask.ts @@ -65,6 +65,7 @@ type RunTaskOptions = { notify?: boolean; workspacePath?: string; settings?: RooCodeSettings; + mode?: string; }; export const runTask = async ({ @@ -78,6 +79,7 @@ export const runTask = async ({ notify = true, workspacePath = '/roo/repos/Roo-Code', settings = {}, + mode, }: RunTaskOptions) => { const ipcSocketPath = path.resolve( os.tmpdir(), @@ -90,15 +92,15 @@ export const runTask = async ({ let envVars = `ROO_CODE_IPC_SOCKET_PATH=${ipcSocketPath}`; - // Create JWT token if we have jobId and userId + // Create JWT token if we have jobId and userId. if (jobId && userId) { try { - // Get user's org info const user = await db .select() .from(users) .where(eq(users.id, userId)) .limit(1); + const orgId = user[0]?.orgId || null; const token = await createJobToken( @@ -109,11 +111,10 @@ export const runTask = async ({ ); envVars += ` ROO_CODE_CLOUD_TOKEN=${token}`; - envVars += ` ROO_CODE_CLOUD_ORG_SETTINGS=${Buffer.from(JSON.stringify(ORGANIZATION_DEFAULT)).toString('base64')}`; } catch (error) { logger?.error('Failed to create job token:', error); - // Continue without token - job will fall back to no auth + // Continue without token - job will fall back to no auth. } } @@ -131,9 +132,10 @@ export const runTask = async ({ logger.info(codeCommand); - // Pull latest changes from git before opening VSCode + // Pull latest changes from git before opening VSCode. try { const repoConfig = getRepoConfigByPath(workspacePath); + if (repoConfig) { logger.info(`Pulling latest changes for repository: ${repoConfig.name}`); await gitPullRepoFromConfig(repoConfig, logger); @@ -148,7 +150,7 @@ export const runTask = async ({ } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to pull git changes: ${errorMessage}`); - // Continue with task execution even if git pull fails + // Continue with task execution even if git pull fails. logger.info('Continuing with task execution despite git pull failure'); } @@ -305,6 +307,7 @@ export const runTask = async ({ openRouterApiKey: process.env.OPENROUTER_API_KEY, lastShownAnnouncementId: 'jun-17-2025-3-21', ...settings, + mode, }, text: prompt, }, diff --git a/apps/roomote/src/lib/slack.ts b/apps/roomote/src/lib/slack.ts index dd4fded8d7..cf0d44ff2d 100644 --- a/apps/roomote/src/lib/slack.ts +++ b/apps/roomote/src/lib/slack.ts @@ -135,6 +135,21 @@ export class SlackNotifier { ], }); } + case 'general.task': { + const payload = jobPayload as JobPayload<'general.task'>; + return await this.postMessage({ + text: `🚀 Task Started`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `🚀 *Task Started*\nWorking on general task in \n*Task:* ${payload.description.slice(0, 200)}${payload.description.length > 200 ? '...' : ''}`, + }, + }, + ], + }); + } default: throw new Error(`Unknown job type: ${jobType}`); } diff --git a/apps/web/.env b/apps/web/.env index c2780343f2..bea6d1ae1e 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -9,3 +9,5 @@ NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/authorized NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/authorized NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/authorized NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/authorized + +ROOMOTE_API_URL=http://localhost:3001 diff --git a/apps/web/.env.production b/apps/web/.env.production index 247b037a12..45a6dbb050 100644 --- a/apps/web/.env.production +++ b/apps/web/.env.production @@ -1,2 +1,4 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsucm9vY29kZS5jb20k NEXT_PUBLIC_CLERK_FRONTEND_API=https://clerk.roocode.com + +ROOMOTE_API_URL=https://roomote-api.fly.dev diff --git a/apps/web/package.json b/apps/web/package.json index b8e06113b8..3410ed2947 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", diff --git a/apps/web/src/actions/auth.ts b/apps/web/src/actions/auth.ts index c73b79a5b4..159654bd88 100644 --- a/apps/web/src/actions/auth.ts +++ b/apps/web/src/actions/auth.ts @@ -8,11 +8,6 @@ import { Env } from '@roo-code-cloud/env'; import { type AuthResult, type ApiAuthResult, isOrgRole } from '@/types'; import { logger } from '@/lib/server'; import { validateJobToken } from '@roo-code-cloud/job-auth'; -// import { -// type AgentTokenPayload, -// validateAgentToken, -// } from '@/lib/server/agent-auth'; -// import { updateAgentUsage } from '@/actions/agents'; export async function authorize(): Promise { const { userId, orgId, orgRole } = await auth(); @@ -21,7 +16,6 @@ export async function authorize(): Promise { return { success: false, error: 'Unauthorized: User required' }; } - // Personal context is valid - no org required if (!orgId) { return { success: true, @@ -41,19 +35,17 @@ export async function authorize(): Promise { }; } -/** - * Validates authentication and authorization for API endpoints. - */ export async function authorizeApi( request: NextRequest, ): Promise { const authHeader = request.headers.get('authorization'); if (!authHeader?.startsWith('Bearer ')) { - return authorize(); // Fall back to Clerk auth + return authorize(); } const token = authHeader.slice(7); + if (!token) { return { success: false, @@ -61,9 +53,9 @@ export async function authorizeApi( }; } - // Try job token first try { const jobContext = await validateJobToken(token); + return { success: true, userType: 'job', @@ -72,17 +64,10 @@ export async function authorizeApi( jobId: jobContext.jobId, }; } catch { - // If job token validation fails, try agent token - // (existing agent token code would go here) - - // If both fail, fall back to Clerk return authorize(); } } -/** - * Validates authentication and authorization for analytics functions. - */ export async function authorizeAnalytics({ requestedOrgId, requestedUserId, @@ -100,9 +85,7 @@ export async function authorizeAnalytics({ throw new Error('Unauthorized: User required'); } - // Handle personal context if (!authOrgId && !requestedOrgId) { - // Personal context - user can only access their own data if (requestedUserId && requestedUserId !== authUserId) { throw new Error( 'Unauthorized: Personal users can only access their own data', @@ -117,17 +100,14 @@ export async function authorizeAnalytics({ }; } - // Ensure user is authenticated and belongs to the organization if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) { throw new Error('Unauthorized: Invalid organization access'); } - // Check if admin access is required if (requireAdmin && orgRole !== 'org:admin') { throw new Error('Unauthorized: Administrator access required'); } - // If user is not an admin and trying to access data other than their own if ( orgRole !== 'org:admin' && requestedUserId && @@ -136,8 +116,6 @@ export async function authorizeAnalytics({ throw new Error('Unauthorized: Members can only access their own data'); } - // For non-admin users, force userId filter to their own ID - // Unless allowCrossUserAccess is true and we're checking task sharing permissions const effectiveUserId = orgRole !== 'org:admin' && !allowCrossUserAccess ? authUserId diff --git a/apps/web/src/actions/roomote.ts b/apps/web/src/actions/roomote.ts new file mode 100644 index 0000000000..44ea922513 --- /dev/null +++ b/apps/web/src/actions/roomote.ts @@ -0,0 +1,238 @@ +'use server'; + +import { z } from 'zod'; +import { desc, eq, and, getTableColumns } from 'drizzle-orm'; + +import { + type CloudJob, + type CreateJob, + type User, + db, + cloudJobs, + orgSettings, + users, + createJobSchema, +} from '@roo-code-cloud/db/server'; + +import { authorizeRoomotes } from '@/lib/roomotes'; + +export type CloudJobWithUser = CloudJob & { + user?: User | null; +}; + +export async function getCloudSettings(): Promise< + | { success: true; data: Record } + | { success: false; error: string } +> { + try { + const authResult = await authorizeRoomotes(); + + if (!authResult.success) { + return { + success: false, + error: authResult.error || 'Roomotes feature not enabled', + }; + } + + if (!authResult.orgId) { + return { success: false, error: 'Organization membership required' }; + } + + const { orgId } = authResult; + + const [row] = await db + .select() + .from(orgSettings) + .where(eq(orgSettings.orgId, orgId)) + .limit(1); + + return { success: true, data: row?.cloudSettings ?? {} }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +export async function updateCloudSettings( + newSettings: Record, +): Promise<{ success: true } | { success: false; error: string }> { + try { + const authResult = await authorizeRoomotes(); + + if (!authResult.success) { + return { + success: false, + error: authResult.error || 'Roomotes feature not enabled', + }; + } + + if (authResult.orgRole !== 'org:admin') { + return { success: false, error: 'Admin access required' }; + } + + const { orgId } = authResult; + + const [row] = await db + .select() + .from(orgSettings) + .where(eq(orgSettings.orgId, orgId)) + .limit(1); + + const cloudSettings = { + ...(row?.cloudSettings || {}), + ...newSettings, + }; + + if (row) { + await db + .update(orgSettings) + .set({ cloudSettings, updatedAt: new Date() }) + .where(eq(orgSettings.orgId, orgId)); + } else { + await db.insert(orgSettings).values({ orgId, cloudSettings }); + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +export async function fetchRoomoteJobs( + userId?: string | null, +): Promise< + | { success: true; jobs: CloudJobWithUser[] } + | { success: false; error: string } +> { + try { + const authResult = await authorizeRoomotes(); + + if (!authResult.success) { + return { + success: false, + error: authResult.error || 'Roomotes feature not enabled', + }; + } + + if (!authResult.orgId) { + return { success: false, error: 'Organization membership required' }; + } + + const { orgId, orgRole } = authResult; + + const conditions = [eq(cloudJobs.orgId, orgId)]; + + if (!userId && orgRole !== 'org:admin') { + userId = authResult.userId; + } + + if (userId) { + conditions.push(eq(cloudJobs.userId, userId)); + } + + const jobs = await db + .select({ + ...getTableColumns(cloudJobs), + user: getTableColumns(users), + }) + .from(cloudJobs) + .leftJoin(users, eq(cloudJobs.userId, users.id)) + .where(conditions.length > 1 ? and(...conditions) : conditions[0]) + .orderBy(desc(cloudJobs.createdAt)) + .limit(50); + + return { success: true, jobs }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +export async function createRoomoteJob( + data: CreateJob, +): Promise< + | { success: true; jobId: number; enqueuedJobId: string } + | { success: false; error: string } +> { + try { + const authResult = await authorizeRoomotes(); + + if (!authResult.success) { + return { + success: false, + error: authResult.error || 'Roomotes feature not enabled', + }; + } + + if (!authResult.orgId) { + return { success: false, error: 'Organization membership required' }; + } + + const { orgId, userId } = authResult; + + const response = await fetch(`${process.env.ROOMOTE_API_URL}/api/jobs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(createJobSchema.parse({ ...data, orgId, userId })), + }); + + if (!response.ok) { + console.error(response); + + const error = + (await response.json().catch(() => undefined)) ?? + `HTTP ${response.status}: ${response.statusText}`; + + return { success: false, error }; + } + + const { jobId, enqueuedJobId } = await response.json(); + return { success: true, jobId, enqueuedJobId }; + } catch (error) { + console.error(error); + + if (error instanceof z.ZodError) { + return { + success: false, + error: `Validation error: ${error.errors.map((e) => e.message).join(', ')}`, + }; + } + + if (error instanceof TypeError) { + const { cause } = error; + + if ( + typeof cause === 'object' && + cause && + 'code' in cause && + cause.code === 'ECONNREFUSED' + ) { + return { + success: false, + error: `Connection refused. Please check if the Roomote API server is running.`, + }; + } + } + + if (error instanceof Error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: 'An unknown error occurred.', + }; + } +} diff --git a/apps/web/src/app/(authenticated)/roomote/ConfigureTasks.tsx b/apps/web/src/app/(authenticated)/roomote/ConfigureTasks.tsx new file mode 100644 index 0000000000..345f978155 --- /dev/null +++ b/apps/web/src/app/(authenticated)/roomote/ConfigureTasks.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; + +import type { JobType } from '@roo-code-cloud/db'; + +import { QueryKey } from '@/types'; +import { getCloudSettings, updateCloudSettings } from '@/actions/roomote'; +import { + Button, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui'; + +import { TASK_TYPES } from './constants'; + +type ConfigureTasksProps = { + onSuccess?: () => void; +}; + +export function ConfigureTasks({ onSuccess }: ConfigureTasksProps) { + const [localChanges, setLocalChanges] = useState< + Partial> + >({}); + + const [selectedTaskType, setSelectedTaskType] = useState( + TASK_TYPES[0]?.key, + ); + + const query = useQuery({ + queryKey: [QueryKey.GetCloudSettings], + queryFn: getCloudSettings, + select: (data) => + data.success && data.data?.roomoteModeMappings + ? (data.data.roomoteModeMappings as Partial>) + : {}, + }); + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: updateCloudSettings, + onSuccess: (result) => { + if (result.success) { + toast.success('Task configuration successfully saved.'); + setLocalChanges({}); + queryClient.invalidateQueries({ + queryKey: [QueryKey.GetCloudSettings], + }); + onSuccess?.(); + } else { + toast.error(result.error); + } + }, + onError: () => toast.error('An unexpected error occurred.'), + }); + + const modes = { ...(query.data || {}), ...localChanges }; + + return query.isLoading ? ( +
+ +
+ ) : ( +
+
+ + + {selectedTaskType && ( + + setLocalChanges((prev) => ({ + ...prev, + [selectedTaskType]: value, + })) + } + disabled={mutation.isPending} + /> + )} +
+ + {Object.keys(localChanges).length > 0 && ( +
+ +
+ )} +
+ ); +} + +interface TaskConfigurationProps { + jobType: JobType; + modes: Partial>; + onChange: (value: string) => void; + disabled: boolean; +} + +function TaskConfiguration({ + jobType, + modes, + onChange, + disabled, +}: TaskConfigurationProps) { + const taskType = TASK_TYPES.find((t) => t.key === jobType); + + return taskType ? ( +
+
+ +

+ {taskType.description} +

+
+ onChange(e.target.value)} + placeholder="Mode" + disabled={disabled} + /> +
+ ) : null; +} diff --git a/apps/web/src/app/(authenticated)/roomote/CreateTask.tsx b/apps/web/src/app/(authenticated)/roomote/CreateTask.tsx new file mode 100644 index 0000000000..6528de5cea --- /dev/null +++ b/apps/web/src/app/(authenticated)/roomote/CreateTask.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAuth } from '@clerk/nextjs'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; + +import { type JobType } from '@roo-code-cloud/db'; + +import { QueryKey } from '@/types'; +import { createRoomoteJob } from '@/actions/roomote'; +import { + Button, + Form, + FormDescription, + FormItem, + FormLabel, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectGroup, + SelectItem, +} from '@/components/ui'; + +import { type FormData, formSchema } from './types'; +import { TASK_TYPES } from './constants'; +import { + GitHubIssueFixFields, + GitHubIssueCommentFields, + GitHubPRCommentFields, + GeneralTaskFields, +} from './components'; + +type CreateTaskProps = { + onSuccess?: () => void; +}; + +export function CreateTask({ onSuccess }: CreateTaskProps) { + const { orgId, userId } = useAuth(); + + const [selectedJobType, setSelectedJobType] = + useState('general.task'); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + orgId: orgId || '', + userId: userId || '', + type: 'general.task', + }, + }); + + const onJobTypeChange = (jobType: JobType) => { + setSelectedJobType(jobType); + form.reset(); + form.setValue('type', jobType); + }; + + const queryClient = useQueryClient(); + + const onSubmit = async (data: FormData) => { + try { + const result = await createRoomoteJob({ ...data }); + + if (result.success) { + toast.success(`Job created successfully (job_id: ${result.jobId}).`); + onJobTypeChange(selectedJobType); + queryClient.invalidateQueries({ + queryKey: [QueryKey.FetchRoomoteJobs], + }); + onSuccess?.(); + } else { + toast.error(result.error); + } + } catch (_error) { + toast.error('An unexpected error occurred.'); + } + }; + + return ( +
+ + + Task Type + + + Choose the type of task you want to create. + + + + {selectedJobType === 'general.task' && } + + {selectedJobType === 'github.issue.fix' && } + + {selectedJobType === 'github.issue.comment.respond' && ( + + )} + + {selectedJobType === 'github.pr.comment.respond' && ( + + )} + + {selectedJobType && ( +
+ +
+ )} + + + ); +} diff --git a/apps/web/src/app/(authenticated)/roomote/Jobs.tsx b/apps/web/src/app/(authenticated)/roomote/Jobs.tsx new file mode 100644 index 0000000000..b8f9a912ed --- /dev/null +++ b/apps/web/src/app/(authenticated)/roomote/Jobs.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; +import { Loader2, RefreshCw, Plus, Cog } from 'lucide-react'; + +import { QueryKey } from '@/types'; +import { cn } from '@/lib'; +import { + Button, + Badge, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui'; +import { fetchRoomoteJobs } from '@/actions/roomote'; + +import { STATUSES, LABELS } from './constants'; +import { getJobTitle } from './utils'; +import { CreateTask } from './CreateTask'; +import { ConfigureTasks } from './ConfigureTasks'; + +interface JobsProps { + userId?: string; +} + +export function Jobs({ userId }: JobsProps) { + const [modal, setModal] = useState<'create' | 'configure'>(); + + const query = useQuery({ + queryKey: [QueryKey.FetchRoomoteJobs, userId], + queryFn: () => fetchRoomoteJobs(userId), + refetchInterval: 120_000, + }); + + const jobs = query.data?.success ? query.data.jobs : []; + + const error = + query.data?.success === false ? query.data.error : query.error?.message; + + const lastUpdated = query.dataUpdatedAt + ? new Date(query.dataUpdatedAt) + : null; + + if (query.isLoading && jobs.length === 0) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } + + return ( + <> +
+
+
+
+ Roomote Tasks +
+
+ Create and manage automated tasks using Roomote. +
+
+
+
+ + + +
+ {lastUpdated && ( +
+ Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })} +
+ )} +
+
+ + {jobs.length === 0 ? ( +
+ No jobs found. Create your first job using the form above. +
+ ) : ( +
+
+
+
ID
+
Type
+
Title
+
Triggered By
+
Status
+
Created
+
Duration
+
+
+ +
+ {jobs.map((job) => { + const { label, variant, icon: Icon } = STATUSES[job.status]; + + const duration = + job.completedAt && job.startedAt + ? Math.round( + (new Date(job.completedAt).getTime() - + new Date(job.startedAt).getTime()) / + 1000, + ) + : null; + + return ( +
+
+
+ #{job.id} +
+ +
+ + {LABELS[job.type]} + +
+ +
+
+ {getJobTitle(job)} +
+ {job.error && ( +
+ Error: {job.error} +
+ )} +
+ +
+ {job.user ? ( +
+
+ {job.user.name} +
+
+ {job.user.email} +
+
+ ) : ( +
+ Unknown user +
+ )} +
+ +
+ + + {label} + +
+ +
+ {formatDistanceToNow(new Date(job.createdAt), { + addSuffix: true, + })} +
+ +
+ {duration ? `${duration}s` : '-'} +
+
+
+ ); + })} +
+
+ )} +
+ + setModal(undefined)} + > + + + Create Task + + Select a task type and fill in the required information to create + a new Roomote task. + + + setModal(undefined)} /> + + + + setModal(undefined)} + > + + + Configure Tasks + + Configure which mode Roomote should use for each type of task. + Enter the mode slug (e.g., "code", + "architect", "ask", "debug", + "orchestrator", "designer", + "security-review"). + + + setModal(undefined)} /> + + + + ); +} diff --git a/apps/web/src/app/(authenticated)/roomote/components/GeneralTaskFields.tsx b/apps/web/src/app/(authenticated)/roomote/components/GeneralTaskFields.tsx new file mode 100644 index 0000000000..43b3effeb7 --- /dev/null +++ b/apps/web/src/app/(authenticated)/roomote/components/GeneralTaskFields.tsx @@ -0,0 +1,65 @@ +import { useFormContext } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@/components/ui'; + +import type { FormData } from '../types'; + +export function GeneralTaskFields() { + const { control } = useFormContext(); + + return ( + <> + ( + + Repository + + + + + GitHub repository in format: owner/repository + + + + )} + /> + + ( + + Task Description + +