From 658fef8cde37403e4e149723c5e4e5609b238be9 Mon Sep 17 00:00:00 2001 From: Govind Kamtamneni Date: Tue, 17 Jun 2025 16:58:52 -0700 Subject: [PATCH 1/4] Adds Codex Playground feature Implements a "Codex Playground" feature that allows users to run natural language coding tasks in a sandboxed GitHub Actions environment. This involves: - Creating a new GitHub Actions workflow (`codex-playground.yml`) that executes the specified task. - Adding API endpoints to start tasks, check task status, and retrieve logs. - Implementing a new UI component (PlaygroundModal) to interact with the new endpoints. - Adds copilot setup steps to ease the integration with Copilot. - Adds general guidelines for Copilot. --- .github/copilot-instructions.md | 36 +++ .github/workflows/codex-playground.yml | 30 +++ .github/workflows/copilot-setup-steps.yml | 38 +++ backend/app/main.py | 95 ++++++- backend/app/models/schemas.py | 26 ++ backend/app/services/github.py | 60 +++++ frontend/src/App.css | 191 ++++++++++++++ frontend/src/components/AgentCard.tsx | 33 ++- frontend/src/components/HomePage.tsx | 2 +- .../src/components/ui/PlaygroundModal.tsx | 248 ++++++++++++++++++ frontend/tsconfig.tsbuildinfo | 2 +- 11 files changed, 752 insertions(+), 9 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/codex-playground.yml create mode 100644 .github/workflows/copilot-setup-steps.yml create mode 100644 frontend/src/components/ui/PlaygroundModal.tsx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ea19e4e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,36 @@ +# Copilot Instructions for agunblock Repository + +## General Guidelines +- Follow the repository's existing coding style and conventions. +- Prefer TypeScript or JavaScript for new code unless otherwise specified. +- Use Node.js 20 features where appropriate. +- Always use `npm ci` for installing dependencies in CI environments. +- Ensure compatibility with GitHub Actions workflows as defined in `.github/workflows/`. + +## GitHub Actions +- Use `actions/checkout@v4` for checking out code. +- Use `actions/setup-node@v4` with `node-version: "20"` and `cache: "npm"` for Node.js setup. +- Keep workflow permissions minimal, e.g., `contents: read` unless more is required. +- Name setup jobs as `copilot-setup-steps` for Copilot compatibility. + +## Code Quality +- Write modular, reusable functions. +- Add comments for complex logic. +- Prefer async/await for asynchronous code. +- Use environment variables for secrets and configuration. + +## Pull Requests & Commits +- Reference related issues in commit messages and PR descriptions. +- Ensure all workflows pass before merging. + +## Dependency Management +- Use `npm ci` for clean, reproducible installs. +- Do not commit `node_modules` or other generated files. + +## Security +- Do not hardcode secrets or credentials. +- Use GitHub Actions secrets for sensitive data. + +## Documentation +- Update relevant documentation for any new features or changes. +- Use Markdown for documentation files. diff --git a/.github/workflows/codex-playground.yml b/.github/workflows/codex-playground.yml new file mode 100644 index 0000000..4498106 --- /dev/null +++ b/.github/workflows/codex-playground.yml @@ -0,0 +1,30 @@ +name: Codex Playground Task +on: + workflow_dispatch: + inputs: + prompt: + description: "Natural-language task for Codex" + required: true + task_id: + description: "Unique task identifier" + required: true + azure_openai_endpoint: + description: "Azure OpenAI endpoint" + required: true + azure_openai_key: + description: "Azure OpenAI API key" + required: true + azure_openai_deployment: + description: "Azure OpenAI deployment name" + required: true + default: "o4-mini" + +jobs: + codex-task: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: false # This ensures the workflow never runs + + steps: + - name: Checkout repository + uses: actions/checkout@v4 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..5e5bd9e --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,38 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. + contents: read + + # You can define any steps you want, and they will run before the agent starts. + # If you do not check out your code, Copilot will do this for you. + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install JavaScript dependencies + run: npm ci diff --git a/backend/app/main.py b/backend/app/main.py index 9ad3d4c..b0ce4c9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ import json import asyncio import os -from .models.schemas import RepositoryAnalysisRequest, RepositoryAnalysisResponse, RepositoryInfoResponse, AnalysisProgressUpdate, TaskBreakdownRequest, TaskBreakdownResponse, Task, DevinSessionRequest, DevinSessionResponse +from .models.schemas import RepositoryAnalysisRequest, RepositoryAnalysisResponse, RepositoryInfoResponse, AnalysisProgressUpdate, TaskBreakdownRequest, TaskBreakdownResponse, Task, DevinSessionRequest, DevinSessionResponse, CodexPlaygroundRequest, CodexPlaygroundResponse, PlaygroundTaskStatus, RunnerTokenRequest from .services.github import GitHubService from .services.agent import AzureAgentService from .config import CORS_ORIGINS @@ -340,3 +340,96 @@ async def create_devin_session(request: DevinSessionRequest): except Exception as e: logger.error(f"Error creating Devin session: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to create Devin session: {str(e)}") + +@app.post("/api/playground/start") +async def start_playground_task( + request: CodexPlaygroundRequest, + github_service: GitHubService = Depends(get_github_service) +): + """Start a new Codex playground task in GitHub Actions.""" + try: + import uuid + task_id = str(uuid.uuid4()) + + workflow_inputs = { + "prompt": request.prompt, + "task_id": task_id, + "azure_openai_endpoint": request.azure_openai_endpoint, + "azure_openai_key": request.azure_openai_key, + "azure_openai_deployment": request.azure_openai_deployment + } + + success = await github_service.dispatch_workflow( + owner=request.owner, + repo=request.repo, + workflow_id="codex-playground.yml", + inputs=workflow_inputs + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to dispatch workflow") + + return CodexPlaygroundResponse( + task_id=task_id, + status="queued" + ) + except Exception as e: + logger.error(f"Error starting playground task: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/playground/status/{task_id}") +async def get_playground_status( + task_id: str, + owner: str, + repo: str, + github_service: GitHubService = Depends(get_github_service) +): + """Get status of a playground task.""" + try: + runs = await github_service.get_workflow_runs(owner, repo, "codex-playground.yml") + + matching_run = None + for run in runs: + if task_id in str(run.get("html_url", "")): + matching_run = run + break + + if not matching_run: + return PlaygroundTaskStatus(task_id=task_id, status="not_found") + + return PlaygroundTaskStatus( + task_id=task_id, + status=matching_run["status"], + workflow_run_id=matching_run["id"], + logs_url=matching_run["html_url"] + ) + except Exception as e: + logger.error(f"Error getting playground status: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/playground/logs/{task_id}") +async def get_playground_logs( + task_id: str, + owner: str, + repo: str, + github_service: GitHubService = Depends(get_github_service) +): + """Get logs URL for a playground task.""" + try: + runs = await github_service.get_workflow_runs(owner, repo, "codex-playground.yml") + + matching_run = None + for run in runs: + if task_id in str(run.get("html_url", "")): + matching_run = run + break + + if not matching_run: + raise HTTPException(status_code=404, detail="Task not found") + + logs_url = await github_service.get_workflow_logs(owner, repo, matching_run["id"]) + + return {"logs_url": logs_url, "workflow_url": matching_run["html_url"]} + except Exception as e: + logger.error(f"Error getting playground logs: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 6cfc2f3..f76693d 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -63,3 +63,29 @@ class DevinSessionRequest(BaseModel): class DevinSessionResponse(BaseModel): session_id: str session_url: str + +class CodexPlaygroundRequest(BaseModel): + owner: str + repo: str + prompt: str + azure_openai_endpoint: str + azure_openai_key: str + azure_openai_deployment: str = "gpt-4o" + +class CodexPlaygroundResponse(BaseModel): + task_id: str + workflow_run_id: Optional[int] = None + runner_name: Optional[str] = None + status: str + +class PlaygroundTaskStatus(BaseModel): + task_id: str + status: str + workflow_run_id: Optional[int] = None + logs_url: Optional[str] = None + artifacts_url: Optional[str] = None + error: Optional[str] = None + +class RunnerTokenRequest(BaseModel): + owner: str + repo: str diff --git a/backend/app/services/github.py b/backend/app/services/github.py index 3797b3b..5bce5f3 100644 --- a/backend/app/services/github.py +++ b/backend/app/services/github.py @@ -261,3 +261,63 @@ async def get_repository_snapshot(self, owner: str, repo: str) -> Optional[Dict[ print(f"Detailed error: {repr(e)}") raise RuntimeError(f"Failed to fetch repository data: {error_message}") + + async def create_runner_token(self, owner: str, repo: str) -> Optional[Dict[str, Any]]: + """Create a registration token for GitHub Actions runners.""" + try: + response = gh().rest.actions.create_registration_token_for_repo( + owner=owner, repo=repo + ).parsed_data + return { + "token": response.token, + "expires_at": response.expires_at + } + except Exception as e: + print(f"Error creating runner token: {str(e)}") + return None + + async def dispatch_workflow(self, owner: str, repo: str, workflow_id: str, inputs: Dict[str, str]) -> Optional[bool]: + """Dispatch a workflow with given inputs.""" + try: + gh().rest.actions.create_workflow_dispatch( + owner=owner, + repo=repo, + workflow_id=workflow_id, + ref="main", + inputs=inputs + ) + return True + except Exception as e: + print(f"Error dispatching workflow: {str(e)}") + return False + + async def get_workflow_runs(self, owner: str, repo: str, workflow_id: str) -> List[Dict[str, Any]]: + """Get recent workflow runs for a workflow.""" + try: + response = gh().rest.actions.list_workflow_runs( + owner=owner, repo=repo, workflow_id=workflow_id + ).parsed_data + return [ + { + "id": run.id, + "status": run.status, + "conclusion": run.conclusion, + "created_at": run.created_at, + "html_url": run.html_url + } + for run in response.workflow_runs[:5] + ] + except Exception as e: + print(f"Error getting workflow runs: {str(e)}") + return [] + + async def get_workflow_logs(self, owner: str, repo: str, run_id: int) -> Optional[str]: + """Get logs for a specific workflow run.""" + try: + response = gh().rest.actions.download_workflow_run_logs( + owner=owner, repo=repo, run_id=run_id + ) + return response.url if hasattr(response, 'url') else None + except Exception as e: + print(f"Error getting workflow logs: {str(e)}") + return None diff --git a/frontend/src/App.css b/frontend/src/App.css index 22fa50d..e7dabd6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2030,4 +2030,195 @@ width: 100%; } } + +/* Playground Modal Styles */ +.playground-modal { + max-width: 600px; + width: 100%; +} + +.playground-header { + margin-bottom: 1.5rem; +} + +.playground-header h3 { + margin: 0 0 0.5rem 0; + color: var(--text-primary); + font-size: 1.25rem; +} + +.playground-header p { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.playground-form { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +.form-group input, +.form-group textarea { + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-light); + color: var(--text-primary); + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--azure-teal); + box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1); +} + +.example-prompts { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.example-prompts span { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 500; +} + +.example-prompt { + background: none; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 0.5rem; + color: var(--text-secondary); + font-size: 0.8rem; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.example-prompt:hover { + border-color: var(--azure-teal); + color: var(--text-primary); + background: rgba(6, 182, 212, 0.05); +} + +.error-message { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(220, 53, 69, 0.1); + border: 1px solid rgba(220, 53, 69, 0.3); + border-radius: 6px; + color: #dc3545; + font-size: 0.9rem; +} + +.playground-tasks { + border-top: 1px solid var(--border-color); + padding-top: 1.5rem; +} + +.playground-tasks h4 { + margin: 0 0 1rem 0; + color: var(--text-primary); + font-size: 1rem; +} + +.task-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 6px; + margin-bottom: 0.75rem; + background: var(--card-bg); +} + +.task-info { + flex: 1; +} + +.task-prompt { + color: var(--text-primary); + font-size: 0.9rem; + margin-bottom: 0.5rem; + line-height: 1.4; +} + +.task-meta { + display: flex; + align-items: center; + gap: 1rem; +} + +.task-status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-text { + font-size: 0.8rem; + font-weight: 500; + text-transform: capitalize; +} + +.status-text.completed { + color: var(--azure-green); +} + +.status-text.failure { + color: #dc3545; +} + +.status-text.in_progress { + color: var(--azure-teal); +} + +.status-text.queued { + color: var(--text-secondary); +} + +.task-time { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.task-link { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 4px; + color: var(--text-secondary); + transition: all 0.2s ease; + text-decoration: none; +} + +.task-link:hover { + background: var(--bg-light); + color: var(--azure-teal); +} \ No newline at end of file diff --git a/frontend/src/components/AgentCard.tsx b/frontend/src/components/AgentCard.tsx index b908037..62b741d 100644 --- a/frontend/src/components/AgentCard.tsx +++ b/frontend/src/components/AgentCard.tsx @@ -4,6 +4,7 @@ import { Agent } from '../types/agent'; import { getAgentLogo } from '../utils/agentUtils'; import SetupModal from './ui/SetupModal'; import MultiDevinModal from './ui/MultiDevinModal'; +import PlaygroundModal from './ui/PlaygroundModal'; interface AgentCardProps { agent: Agent; @@ -67,6 +68,7 @@ const AgentCard: React.FC = ({ }) => { const [setupModalOpen, setSetupModalOpen] = useState(false); const [multiDevinModalOpen, setMultiDevinModalOpen] = useState(false); + const [playgroundModalOpen, setPlaygroundModalOpen] = useState(false); return (
@@ -171,12 +173,22 @@ const AgentCard: React.FC = ({
{showAnalyzeButton && onAnalyzeClick ? ( - + <> + + {agent.id === 'codex-cli' && ( + + )} + ) : ( <>
); }; diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index 5930bbe..6a5b3ef 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -41,7 +41,7 @@ const HomePage: React.FC = () => { }; const [activeCategory, setActiveCategory] = - useState('async-swe'); + useState('all'); const filteredAgents = agents.filter( (a) => activeCategory === 'all' || a.category === activeCategory ); diff --git a/frontend/src/components/ui/PlaygroundModal.tsx b/frontend/src/components/ui/PlaygroundModal.tsx new file mode 100644 index 0000000..d0d64f7 --- /dev/null +++ b/frontend/src/components/ui/PlaygroundModal.tsx @@ -0,0 +1,248 @@ +import React, { useState } from 'react'; +import { Play, ExternalLink, Clock, CheckCircle, AlertCircle } from 'lucide-react'; +import Modal from './Modal'; + +interface PlaygroundModalProps { + isOpen: boolean; + onClose: () => void; + repoOwner: string; + repoName: string; +} + +interface PlaygroundTask { + id: string; + prompt: string; + status: 'queued' | 'in_progress' | 'completed' | 'failure' | 'cancelled'; + workflowUrl?: string; + createdAt: Date; +} + +const PlaygroundModal: React.FC = ({ + isOpen, + onClose, + repoOwner, + repoName +}) => { + const [prompt, setPrompt] = useState(''); + const [azureEndpoint, setAzureEndpoint] = useState(''); + const [azureKey, setAzureKey] = useState(''); + const [azureDeployment, setAzureDeployment] = useState('gpt-4o'); + const [tasks, setTasks] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!prompt.trim() || !azureEndpoint || !azureKey) return; + + setIsSubmitting(true); + setError(null); + + try { + const apiUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8000'; + const response = await fetch(`${apiUrl}/api/playground/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner: repoOwner, + repo: repoName, + prompt, + azure_openai_endpoint: azureEndpoint, + azure_openai_key: azureKey, + azure_openai_deployment: azureDeployment + }) + }); + + if (response.ok) { + const result = await response.json(); + const newTask: PlaygroundTask = { + id: result.task_id, + prompt, + status: 'queued', + createdAt: new Date() + }; + setTasks(prev => [newTask, ...prev]); + setPrompt(''); + + setTimeout(() => { + pollTaskStatus(result.task_id); + }, 2000); + } else { + const errorData = await response.json(); + setError(errorData.detail || 'Failed to start task'); + } + } catch (error) { + console.error('Error starting playground task:', error); + setError('Failed to start task. Please check your connection.'); + } finally { + setIsSubmitting(false); + } + }; + + const pollTaskStatus = async (taskId: string) => { + try { + const apiUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8000'; + const response = await fetch( + `${apiUrl}/api/playground/status/${taskId}?owner=${repoOwner}&repo=${repoName}` + ); + + if (response.ok) { + const status = await response.json(); + setTasks(prev => prev.map(task => + task.id === taskId + ? { ...task, status: status.status, workflowUrl: status.logs_url } + : task + )); + + if (status.status === 'in_progress' || status.status === 'queued') { + setTimeout(() => pollTaskStatus(taskId), 5000); + } + } + } catch (error) { + console.error('Error polling task status:', error); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return ; + case 'failure': + return ; + case 'in_progress': + return ; + default: + return ; + } + }; + + const examplePrompts = [ + "Create a Python function to calculate fibonacci numbers", + "Write a React component for a todo list", + "Generate a SQL query to find top customers by revenue", + "Create a bash script to backup files" + ]; + + return ( + +
+
+

Run Codex CLI in GitHub Actions

+

Execute natural language coding tasks for {repoOwner}/{repoName}

+
+ +
+
+ + setAzureEndpoint(e.target.value)} + placeholder="https://your-resource.openai.azure.com/" + required + /> +
+ +
+ + setAzureKey(e.target.value)} + placeholder="Your Azure OpenAI API key" + required + /> +
+ +
+ + setAzureDeployment(e.target.value)} + placeholder="gpt-4o" + required + /> +
+ +
+ +