Adjusted local project setup directory tree structure to map branches on project repos

This commit is contained in:
Renn F
2025-12-30 19:36:36 +01:00
parent 22d1401020
commit d7e93ece25
8 changed files with 651 additions and 61 deletions
+135 -32
View File
@@ -3,10 +3,27 @@ Git API Routes
Git operations for agents working on code tasks. Git operations for agents working on code tasks.
These endpoints are called by the Git MCP Server. These endpoints are called by the Git MCP Server.
Workspace Structure:
Each agent gets their own workspace (git clone) for a project:
{workspaces_root}/
└── {project-slug}/
└── {team}/
└── {agent-slug}/
└── [git repo files]
Example:
/data/workspaces/roboco/backend/be-dev-1/
/data/workspaces/roboco/backend/be-dev-2/
This allows multiple agents to work on the same project in parallel,
each on their own branch, without file conflicts.
""" """
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
@@ -31,7 +48,9 @@ from roboco.api.schemas.git import (
GitPushResponse, GitPushResponse,
GitStatusResponse, GitStatusResponse,
) )
from roboco.config import settings
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.workspace import WorkspaceError, get_workspace_service
router = APIRouter() router = APIRouter()
@@ -80,26 +99,82 @@ async def _run_git(
async def _get_workspace( async def _get_workspace(
db: DbSession, db: DbSession,
project_slug: str, project_slug: str,
agent_id: UUID | None = None,
) -> Path: ) -> Path:
"""Get the workspace path for a project.""" """
service = get_project_service(db) Get the workspace path for an agent on a project.
project = await service.get_by_slug(project_slug)
Uses multi-agent workspace structure:
{workspaces_root}/{project_slug}/{team}/{agent_slug}/
If workspace doesn't exist and auto_clone is enabled, clones the repo.
Args:
db: Database session
project_slug: Project identifier
agent_id: Agent UUID (uses workspace service to resolve path)
Returns:
Path to the workspace directory
Raises:
HTTPException: If project not found or workspace setup fails
"""
# Get project info
project_service = get_project_service(db)
project = await project_service.get_by_slug(project_slug)
if not project: if not project:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project '{project_slug}' not found", detail=f"Project '{project_slug}' not found",
) )
if not project.workspace_path:
# If no agent_id, fall back to legacy workspace_path (for backwards compat)
if agent_id is None:
if not project.workspace_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Project '{project_slug}' has no workspace configured "
"and no agent_id provided for dynamic workspace resolution",
)
workspace = Path(project.workspace_path)
if not workspace.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace path does not exist: {workspace}",
)
return workspace
# Use workspace service for multi-agent workspace resolution
workspace_service = get_workspace_service(db)
try:
if settings.workspace_auto_clone:
# Ensure workspace exists (clone if needed)
workspace = await workspace_service.ensure_workspace(
project_slug=project_slug,
agent_id=agent_id,
git_url=project.git_url,
default_branch=project.default_branch or "main",
)
else:
# Just resolve path, don't auto-clone
workspace = await workspace_service.resolve_workspace(
project_slug=project_slug,
agent_id=agent_id,
)
if not workspace.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace does not exist: {workspace}. "
"Clone the repository first or enable auto_clone.",
)
except WorkspaceError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Project '{project_slug}' has no workspace configured", detail=str(e),
) ) from e
workspace = Path(project.workspace_path)
if not workspace.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace path does not exist: {workspace}",
)
return workspace return workspace
@@ -111,12 +186,12 @@ async def _get_workspace(
@router.get("/status", response_model=GitStatusResponse) @router.get("/status", response_model=GitStatusResponse)
async def get_git_status( async def get_git_status(
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
project_slug: str = Query(...), project_slug: str = Query(...),
_task_id: str | None = Query(default=None), _task_id: str | None = Query(default=None),
) -> GitStatusResponse: ) -> GitStatusResponse:
"""Get git status for a project.""" """Get git status for a project."""
workspace = await _get_workspace(db, project_slug) workspace = await _get_workspace(db, project_slug, agent.agent_id)
# Get current branch # Get current branch
branch_result = await _run_git(workspace, ["branch", "--show-current"]) branch_result = await _run_git(workspace, ["branch", "--show-current"])
@@ -174,13 +249,13 @@ async def get_git_status(
@router.get("/log", response_model=GitLogResponse) @router.get("/log", response_model=GitLogResponse)
async def get_git_log( async def get_git_log(
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
project_slug: str = Query(...), project_slug: str = Query(...),
limit: int = Query(default=10, le=50), limit: int = Query(default=10, le=50),
branch: str | None = Query(default=None), branch: str | None = Query(default=None),
) -> GitLogResponse: ) -> GitLogResponse:
"""Get git log for a project.""" """Get git log for a project."""
workspace = await _get_workspace(db, project_slug) workspace = await _get_workspace(db, project_slug, agent.agent_id)
# Get current branch if not specified # Get current branch if not specified
if not branch: if not branch:
@@ -222,12 +297,12 @@ async def get_git_log(
@router.get("/branches", response_model=GitBranchListResponse) @router.get("/branches", response_model=GitBranchListResponse)
async def list_branches( async def list_branches(
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
project_slug: str = Query(...), project_slug: str = Query(...),
include_remote: bool = Query(default=False), include_remote: bool = Query(default=False),
) -> GitBranchListResponse: ) -> GitBranchListResponse:
"""List git branches for a project.""" """List git branches for a project."""
workspace = await _get_workspace(db, project_slug) workspace = await _get_workspace(db, project_slug, agent.agent_id)
# Get current branch # Get current branch
current_result = await _run_git(workspace, ["branch", "--show-current"]) current_result = await _run_git(workspace, ["branch", "--show-current"])
@@ -271,13 +346,13 @@ async def list_branches(
@router.get("/diff", response_model=GitDiffResponse) @router.get("/diff", response_model=GitDiffResponse)
async def get_git_diff( async def get_git_diff(
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
project_slug: str = Query(...), project_slug: str = Query(...),
staged: bool = Query(default=False), staged: bool = Query(default=False),
file_path: str | None = Query(default=None), file_path: str | None = Query(default=None),
) -> GitDiffResponse: ) -> GitDiffResponse:
"""Get git diff for a project.""" """Get git diff for a project."""
workspace = await _get_workspace(db, project_slug) workspace = await _get_workspace(db, project_slug, agent.agent_id)
args = ["diff"] args = ["diff"]
if staged: if staged:
@@ -312,10 +387,10 @@ async def get_git_diff(
async def create_commit( async def create_commit(
data: GitCommitRequest, data: GitCommitRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitCommitResponse: ) -> GitCommitResponse:
"""Create a git commit.""" """Create a git commit and link it to the task."""
workspace = await _get_workspace(db, data.project_slug) workspace = await _get_workspace(db, data.project_slug, agent.agent_id)
# Stage files # Stage files
if data.files: if data.files:
@@ -347,6 +422,34 @@ async def create_commit(
if "file" in part: if "file" in part:
files_changed = int(part.strip().split()[0]) files_changed = int(part.strip().split()[0])
# Link commit to task (ensures traceability)
from roboco.services.task import get_task_service
from roboco.services.work_session import get_work_session_service
try:
task_uuid = UUID(data.task_id)
# Add commit to task record
task_service = get_task_service(db)
await task_service.add_commit(
task_id=task_uuid,
commit_hash=commit_hash,
message=data.message, # Store original message without prefix
author_id=agent.agent_id,
)
# If task has a work session, add commit there too
task = await task_service.get(task_uuid)
if task and task.work_session_id:
work_session_service = get_work_session_service(db)
await work_session_service.add_commit(task.work_session_id, commit_hash)
await db.commit()
except Exception:
# Don't fail the commit response if linking fails
# The commit was still made successfully
pass
return GitCommitResponse( return GitCommitResponse(
commit_hash=commit_hash, commit_hash=commit_hash,
message=message, message=message,
@@ -360,10 +463,10 @@ async def create_commit(
async def push_commits( async def push_commits(
data: GitPushRequest, data: GitPushRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitPushResponse: ) -> GitPushResponse:
"""Push commits to remote.""" """Push commits to remote."""
workspace = await _get_workspace(db, data.project_slug) workspace = await _get_workspace(db, data.project_slug, agent.agent_id)
# Get current branch # Get current branch
branch_result = await _run_git(workspace, ["branch", "--show-current"]) branch_result = await _run_git(workspace, ["branch", "--show-current"])
@@ -401,7 +504,7 @@ async def create_branch(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitCreateBranchResponse: ) -> GitCreateBranchResponse:
"""Create a task branch (PM only).""" """Create a task branch (PM only)."""
workspace = await _get_workspace(db, data.project_slug) workspace = await _get_workspace(db, data.project_slug, agent.agent_id)
# Get team from agent context # Get team from agent context
team = agent.team or "unknown" team = agent.team or "unknown"
@@ -433,10 +536,10 @@ async def create_branch(
async def checkout_branch( async def checkout_branch(
data: GitCheckoutRequest, data: GitCheckoutRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitCheckoutResponse: ) -> GitCheckoutResponse:
"""Checkout a branch.""" """Checkout a branch."""
workspace = await _get_workspace(db, data.project_slug) workspace = await _get_workspace(db, data.project_slug, agent.agent_id)
await _run_git(workspace, ["checkout", data.branch]) await _run_git(workspace, ["checkout", data.branch])
@@ -450,7 +553,7 @@ async def checkout_branch(
async def create_pull_request( async def create_pull_request(
data: GitCreatePRRequest, data: GitCreatePRRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitCreatePRResponse: ) -> GitCreatePRResponse:
"""Create a pull request using GitHub CLI. """Create a pull request using GitHub CLI.
@@ -460,7 +563,7 @@ async def create_pull_request(
- Developer sets pr_created=True (this endpoint) - Developer sets pr_created=True (this endpoint)
- When BOTH are true, task transitions to awaiting_pm_review - When BOTH are true, task transitions to awaiting_pm_review
""" """
workspace = await _get_workspace(db, data.project_slug) workspace = await _get_workspace(db, data.project_slug, agent.agent_id)
# Get current branch # Get current branch
branch_result = await _run_git(workspace, ["branch", "--show-current"]) branch_result = await _run_git(workspace, ["branch", "--show-current"])
@@ -538,10 +641,10 @@ async def create_pull_request(
async def merge_pull_request( async def merge_pull_request(
data: GitMergePRRequest, data: GitMergePRRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitMergePRResponse: ) -> GitMergePRResponse:
"""Merge a pull request using GitHub CLI (PM only).""" """Merge a pull request using GitHub CLI (PM only)."""
workspace = await _get_workspace(db, data.project_slug) workspace = await _get_workspace(db, data.project_slug, agent.agent_id)
# Merge PR using gh CLI (wrapped in thread to avoid blocking) # Merge PR using gh CLI (wrapped in thread to avoid blocking)
import asyncio import asyncio
+5 -1
View File
@@ -41,6 +41,7 @@ from roboco.api.schemas.tasks import (
TaskSessionLinkResponse, TaskSessionLinkResponse,
TaskUpdate, TaskUpdate,
TeamTasksQuery, TeamTasksQuery,
enrich_task_with_context,
task_list_to_response, task_list_to_response,
task_to_response, task_to_response,
transform_update_data, transform_update_data,
@@ -305,7 +306,7 @@ async def get_task(
task_id: UUID, task_id: UUID,
db: DbSession, db: DbSession,
) -> TaskResponse: ) -> TaskResponse:
"""Get a specific task with linked sessions.""" """Get a specific task with full context (sessions, work session, project)."""
service = get_task_service(db) service = get_task_service(db)
task = await service.get(task_id) task = await service.get(task_id)
if not task: if not task:
@@ -331,6 +332,9 @@ async def get_task(
if link.session and link.session.group and link.session.group.channel if link.session and link.session.group and link.session.group.channel
] ]
# Enrich with work session and project context
response = await enrich_task_with_context(response, db)
return response return response
+79 -22
View File
@@ -3,10 +3,14 @@ Test API Routes
Test and CI/CD operations for agents working on code tasks. Test and CI/CD operations for agents working on code tasks.
These endpoints are called by the Test MCP Server. These endpoints are called by the Test MCP Server.
Uses multi-agent workspace structure - each agent gets their own
workspace at: {workspaces_root}/{project_slug}/{team}/{agent_slug}/
""" """
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
@@ -26,7 +30,9 @@ from roboco.api.schemas.test import (
TypecheckRequest, TypecheckRequest,
TypecheckResponse, TypecheckResponse,
) )
from roboco.config import settings
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.workspace import WorkspaceError, get_workspace_service
router = APIRouter() router = APIRouter()
@@ -43,8 +49,13 @@ _TYPE_ERROR_PARTS_MIN = 3
async def _get_project_and_workspace( async def _get_project_and_workspace(
db: DbSession, db: DbSession,
project_slug: str, project_slug: str,
agent_id: UUID | None = None,
) -> tuple[object, Path]: ) -> tuple[object, Path]:
"""Get the project and workspace path.""" """
Get the project and workspace path for an agent.
Uses multi-agent workspace structure if agent_id is provided.
"""
service = get_project_service(db) service = get_project_service(db)
project = await service.get_by_slug(project_slug) project = await service.get_by_slug(project_slug)
if not project: if not project:
@@ -52,17 +63,51 @@ async def _get_project_and_workspace(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project '{project_slug}' not found", detail=f"Project '{project_slug}' not found",
) )
if not project.workspace_path:
# If no agent_id, fall back to legacy workspace_path
if agent_id is None:
if not project.workspace_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Project '{project_slug}' has no workspace configured "
"and no agent_id provided for dynamic workspace resolution",
)
workspace = Path(project.workspace_path)
if not workspace.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace path does not exist: {workspace}",
)
return project, workspace
# Use workspace service for multi-agent workspace resolution
workspace_service = get_workspace_service(db)
try:
if settings.workspace_auto_clone:
workspace = await workspace_service.ensure_workspace(
project_slug=project_slug,
agent_id=agent_id,
git_url=project.git_url,
default_branch=project.default_branch or "main",
)
else:
workspace = await workspace_service.resolve_workspace(
project_slug=project_slug,
agent_id=agent_id,
)
if not workspace.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace does not exist: {workspace}. "
"Clone the repository first or enable auto_clone.",
)
except WorkspaceError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Project '{project_slug}' has no workspace configured", detail=str(e),
) ) from e
workspace = Path(project.workspace_path)
if not workspace.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace path does not exist: {workspace}",
)
return project, workspace return project, workspace
@@ -102,12 +147,14 @@ async def _run_command(
@router.get("/status", response_model=TestStatusResponse) @router.get("/status", response_model=TestStatusResponse)
async def get_test_status( async def get_test_status(
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
project_slug: str = Query(...), project_slug: str = Query(...),
_task_id: str | None = Query(default=None), _task_id: str | None = Query(default=None),
) -> TestStatusResponse: ) -> TestStatusResponse:
"""Get test status for a project.""" """Get test status for a project."""
_project, _workspace = await _get_project_and_workspace(db, project_slug) _project, _workspace = await _get_project_and_workspace(
db, project_slug, agent.agent_id
)
# Return status - in a full implementation this would query stored results # Return status - in a full implementation this would query stored results
return TestStatusResponse( return TestStatusResponse(
@@ -127,10 +174,12 @@ async def get_test_status(
async def run_tests( async def run_tests(
data: TestRunRequest, data: TestRunRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> TestRunResponse: ) -> TestRunResponse:
"""Run tests for a project.""" """Run tests for a project."""
project, workspace = await _get_project_and_workspace(db, data.project_slug) project, workspace = await _get_project_and_workspace(
db, data.project_slug, agent.agent_id
)
test_cmd = getattr(project, "test_command", None) test_cmd = getattr(project, "test_command", None)
if not test_cmd: if not test_cmd:
@@ -190,10 +239,12 @@ async def run_tests(
async def run_lint( async def run_lint(
data: LintRequest, data: LintRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> LintResponse: ) -> LintResponse:
"""Run linter for a project.""" """Run linter for a project."""
project, workspace = await _get_project_and_workspace(db, data.project_slug) project, workspace = await _get_project_and_workspace(
db, data.project_slug, agent.agent_id
)
lint_cmd = getattr(project, "lint_command", None) lint_cmd = getattr(project, "lint_command", None)
if not lint_cmd: if not lint_cmd:
@@ -253,10 +304,12 @@ async def run_lint(
async def run_format( async def run_format(
data: FormatRequest, data: FormatRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> FormatResponse: ) -> FormatResponse:
"""Run formatter for a project.""" """Run formatter for a project."""
project, workspace = await _get_project_and_workspace(db, data.project_slug) project, workspace = await _get_project_and_workspace(
db, data.project_slug, agent.agent_id
)
format_cmd = getattr(project, "format_command", None) format_cmd = getattr(project, "format_command", None)
if not format_cmd: if not format_cmd:
@@ -295,10 +348,12 @@ async def run_format(
async def run_typecheck( async def run_typecheck(
data: TypecheckRequest, data: TypecheckRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> TypecheckResponse: ) -> TypecheckResponse:
"""Run type checker for a project.""" """Run type checker for a project."""
project, workspace = await _get_project_and_workspace(db, data.project_slug) project, workspace = await _get_project_and_workspace(
db, data.project_slug, agent.agent_id
)
typecheck_cmd = getattr(project, "typecheck_command", None) typecheck_cmd = getattr(project, "typecheck_command", None)
if not typecheck_cmd: if not typecheck_cmd:
@@ -350,10 +405,12 @@ async def run_typecheck(
async def run_build( async def run_build(
data: BuildRequest, data: BuildRequest,
db: DbSession, db: DbSession,
_agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> BuildResponse: ) -> BuildResponse:
"""Run build command for a project.""" """Run build command for a project."""
project, workspace = await _get_project_and_workspace(db, data.project_slug) project, workspace = await _get_project_and_workspace(
db, data.project_slug, agent.agent_id
)
build_cmd = getattr(project, "build_command", None) build_cmd = getattr(project, "build_command", None)
if not build_cmd: if not build_cmd:
+97
View File
@@ -61,6 +61,29 @@ class TaskSessionLinkResponse(BaseModel):
relationship_type: str relationship_type: str
class WorkSessionSummaryInTask(BaseModel):
"""Work session info embedded in task response."""
id: UUID
branch_name: str
status: str
commits: list[str] = []
files_modified: list[str] = []
pr_number: int | None = None
pr_url: str | None = None
pr_status: str | None = None
class ProjectSummaryInTask(BaseModel):
"""Project info embedded in task response."""
id: UUID
name: str
slug: str
git_url: str
default_branch: str
class SubTaskResponse(BaseModel): class SubTaskResponse(BaseModel):
"""A sub-task within a task plan.""" """A sub-task within a task plan."""
@@ -244,6 +267,13 @@ class TaskResponse(BaseModel):
# Linked Sessions (for agent context) # Linked Sessions (for agent context)
sessions: list[TaskSessionLinkResponse] = [] sessions: list[TaskSessionLinkResponse] = []
# Git/Development Context (for full traceability)
project: ProjectSummaryInTask | None = None
work_session: WorkSessionSummaryInTask | None = None
branch_name: str | None = None
pr_number: int | None = None
pr_url: str | None = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -526,9 +556,76 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
# Review Status # Review Status
self_verified=task.self_verified, self_verified=task.self_verified,
qa_verified=task.qa_verified, qa_verified=task.qa_verified,
# Git context from task record
branch_name=getattr(task, "branch_name", None),
pr_number=getattr(task, "pr_number", None),
pr_url=getattr(task, "pr_url", None),
) )
async def enrich_task_with_context(
task_response: TaskResponse,
db: Any,
include_project: bool = True,
include_work_session: bool = True,
) -> TaskResponse:
"""
Enrich a TaskResponse with related context (project, work session).
Call this when full traceability context is needed.
"""
from sqlalchemy import select # noqa: PLC0415
from roboco.db.tables import ProjectTable, WorkSessionTable # noqa: PLC0415
task_dict = task_response.model_dump()
# Get project info if task has project_id
if include_project:
# Task doesn't have project_id in response yet, need to fetch from related data
# This requires knowing the project_id from the task record
pass # TODO: Add project_id to TaskResponse or fetch via work session
# Get work session info
if include_work_session and hasattr(task_response, "id"):
query = select(WorkSessionTable).where(
WorkSessionTable.task_id == task_response.id
)
result = await db.execute(query)
work_session = result.scalar_one_or_none()
if work_session:
task_dict["work_session"] = WorkSessionSummaryInTask(
id=work_session.id,
branch_name=work_session.branch_name,
status=work_session.status.value if work_session.status else "unknown",
commits=list(work_session.commits or []),
files_modified=list(work_session.files_modified or []),
pr_number=work_session.pr_number,
pr_url=work_session.pr_url,
pr_status=work_session.pr_status,
)
# Also get project info from work session
if include_project and work_session.project_id:
proj_query = select(ProjectTable).where(
ProjectTable.id == work_session.project_id
)
proj_result = await db.execute(proj_query)
project = proj_result.scalar_one_or_none()
if project:
task_dict["project"] = ProjectSummaryInTask(
id=project.id,
name=project.name,
slug=project.slug,
git_url=project.git_url,
default_branch=project.default_branch,
)
return TaskResponse(**task_dict)
def task_list_to_response(tasks: list["TaskTable"]) -> list[TaskResponse]: def task_list_to_response(tasks: list["TaskTable"]) -> list[TaskResponse]:
"""Convert list of TaskTable to list of TaskResponse.""" """Convert list of TaskTable to list of TaskResponse."""
return [task_to_response(t) for t in tasks] return [task_to_response(t) for t in tasks]
+17
View File
@@ -213,6 +213,23 @@ class Settings(BaseSettings):
session_max_content_length: int = Field(default=50000, ge=1) session_max_content_length: int = Field(default=50000, ge=1)
message_max_length: int = Field(default=10000, ge=1) message_max_length: int = Field(default=10000, ge=1)
# ==========================================================================
# Workspaces (Multi-Agent Git)
# ==========================================================================
workspaces_root: str = Field(
default="/data/workspaces",
description="Root directory for all agent workspaces",
)
workspace_auto_clone: bool = Field(
default=True,
description="Automatically clone repos when workspace is first accessed",
)
workspace_clone_timeout: int = Field(
default=300,
ge=30,
description="Timeout in seconds for git clone operations",
)
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+1
View File
@@ -33,6 +33,7 @@ class TaskStatus(str, Enum):
AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" # PMs approved, CEO decides AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" # PMs approved, CEO decides
COMPLETED = "completed" COMPLETED = "completed"
CANCELLED = "cancelled" CANCELLED = "cancelled"
QUARANTINED = "quarantined" # Special state for problematic tasks
class TaskType(str, Enum): class TaskType(str, Enum):
+311
View File
@@ -0,0 +1,311 @@
"""
Workspace Service
Manages multi-agent workspaces for git operations.
Each agent gets their own workspace (git clone) for a project, allowing
parallel development without conflicts:
/data/workspaces/
{project-slug}/
{team}/
{agent-slug}/
[git repo files]
Example:
/data/workspaces/roboco/backend/be-dev-1/
/data/workspaces/roboco/backend/be-dev-2/
/data/workspaces/roboco/frontend/fe-dev-1/
"""
import asyncio
import subprocess
from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.logging import get_logger
from roboco.models.base import Team
logger = get_logger(__name__)
class WorkspaceError(Exception):
"""Raised when workspace operations fail."""
pass
class WorkspaceService:
"""
Service for managing agent workspaces.
Workspaces follow the structure:
{workspaces_root}/{project_slug}/{team}/{agent_slug}/
This allows:
- Multiple agents to work on the same project in parallel
- Each agent has their own git working tree
- Agents can be on different branches simultaneously
- No file locking conflicts between agents
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.root = Path(settings.workspaces_root)
def get_workspace_path(
self,
project_slug: str,
team: Team | str,
agent_slug: str,
) -> Path:
"""
Compute the workspace path for an agent on a project.
Args:
project_slug: Project identifier (e.g., 'roboco')
team: Agent's team (e.g., Team.BACKEND or 'backend')
agent_slug: Agent identifier (e.g., 'be-dev-1')
Returns:
Path to the workspace directory
Example:
>>> get_workspace_path('roboco', Team.BACKEND, 'be-dev-1')
Path('/data/workspaces/roboco/backend/be-dev-1')
"""
team_str = team.value if isinstance(team, Team) else str(team)
return self.root / project_slug / team_str / agent_slug
async def resolve_workspace(
self,
project_slug: str,
agent_id: UUID | str,
) -> Path:
"""
Resolve workspace path from project slug and agent ID.
Looks up the agent to get team and slug, then computes path.
Args:
project_slug: Project identifier
agent_id: Agent UUID or slug
Returns:
Path to the workspace directory
Raises:
WorkspaceError: If agent not found
"""
from sqlalchemy import select
# Look up agent
agent_id_str = str(agent_id)
# Try by UUID first, then by slug
query = select(AgentTable)
try:
agent_uuid = UUID(agent_id_str)
query = query.where(AgentTable.id == agent_uuid)
except ValueError:
query = query.where(AgentTable.slug == agent_id_str)
result = await self.session.execute(query)
agent = result.scalar_one_or_none()
if not agent:
raise WorkspaceError(f"Agent not found: {agent_id}")
team = agent.team if agent.team else Team.BACKEND
return self.get_workspace_path(project_slug, team, agent.slug)
async def ensure_workspace(
self,
project_slug: str,
agent_id: UUID | str,
git_url: str | None = None,
default_branch: str = "main",
) -> Path:
"""
Ensure workspace exists, cloning if necessary.
Args:
project_slug: Project identifier
agent_id: Agent UUID or slug
git_url: Git URL to clone (fetched from project if not provided)
default_branch: Default branch to checkout
Returns:
Path to the workspace directory
Raises:
WorkspaceError: If workspace creation fails
"""
workspace = await self.resolve_workspace(project_slug, agent_id)
# Check if already exists
if (workspace / ".git").exists():
logger.debug(
"Workspace already exists",
workspace=str(workspace),
project=project_slug,
)
return workspace
# Get git URL if not provided
if not git_url:
from sqlalchemy import select
result = await self.session.execute(
select(ProjectTable).where(ProjectTable.slug == project_slug)
)
project = result.scalar_one_or_none()
if not project:
raise WorkspaceError(f"Project not found: {project_slug}")
git_url = project.git_url
default_branch = project.default_branch or default_branch
# Clone the repository
await self._clone_repo(workspace, git_url, default_branch)
return workspace
async def _clone_repo(
self,
workspace: Path,
git_url: str,
default_branch: str,
) -> None:
"""
Clone a git repository to the workspace.
Args:
workspace: Target directory
git_url: Git URL to clone
default_branch: Branch to checkout
Raises:
WorkspaceError: If clone fails
"""
# Create parent directories
workspace.parent.mkdir(parents=True, exist_ok=True)
logger.info(
"Cloning repository",
workspace=str(workspace),
git_url=git_url,
branch=default_branch,
)
def _do_clone() -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"git",
"clone",
"--branch",
default_branch,
"--single-branch",
git_url,
str(workspace),
],
capture_output=True,
text=True,
timeout=settings.workspace_clone_timeout,
check=True,
)
try:
await asyncio.to_thread(_do_clone)
logger.info(
"Repository cloned successfully",
workspace=str(workspace),
)
except subprocess.CalledProcessError as e:
raise WorkspaceError(
f"Failed to clone repository: {e.stderr or e.stdout}"
) from e
except subprocess.TimeoutExpired as e:
raise WorkspaceError(
f"Clone timed out after {settings.workspace_clone_timeout}s"
) from e
async def workspace_exists(
self,
project_slug: str,
agent_id: UUID | str,
) -> bool:
"""Check if a workspace exists for the given project and agent."""
try:
workspace = await self.resolve_workspace(project_slug, agent_id)
return (workspace / ".git").exists()
except WorkspaceError:
return False
async def list_workspaces(self, project_slug: str) -> list[dict]:
"""
List all workspaces for a project.
Returns:
List of workspace info dicts with team, agent, and path
"""
project_dir = self.root / project_slug
if not project_dir.exists():
return []
workspaces = []
for team_dir in project_dir.iterdir():
if not team_dir.is_dir():
continue
for agent_dir in team_dir.iterdir():
if not agent_dir.is_dir():
continue
if (agent_dir / ".git").exists():
workspaces.append(
{
"team": team_dir.name,
"agent": agent_dir.name,
"path": str(agent_dir),
"exists": True,
}
)
return workspaces
async def delete_workspace(
self,
project_slug: str,
agent_id: UUID | str,
) -> bool:
"""
Delete a workspace (use with caution).
Args:
project_slug: Project identifier
agent_id: Agent UUID or slug
Returns:
True if deleted, False if didn't exist
"""
import shutil
workspace = await self.resolve_workspace(project_slug, agent_id)
if not workspace.exists():
return False
logger.warning(
"Deleting workspace",
workspace=str(workspace),
)
def _do_delete() -> None:
shutil.rmtree(workspace)
await asyncio.to_thread(_do_delete)
return True
def get_workspace_service(session: AsyncSession) -> WorkspaceService:
"""Factory function to get workspace service."""
return WorkspaceService(session)
Generated
+6 -6
View File
@@ -847,14 +847,14 @@ wheels = [
[[package]] [[package]]
name = "faker" name = "faker"
version = "39.0.0" version = "40.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "tzdata" }, { name = "tzdata" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/30/b9/0897fb5888ddda099dc0f314a8a9afb5faa7e52eaf6865c00686dfb394db/faker-39.0.0.tar.gz", hash = "sha256:ddae46d3b27e01cea7894651d687b33bcbe19a45ef044042c721ceac6d3da0ff", size = 1941757, upload-time = "2025-12-17T19:19:04.762Z" } sdist = { url = "https://files.pythonhosted.org/packages/d7/1d/aa43ef59589ddf3647df918143f1bac9eb004cce1c43124ee3347061797d/faker-40.1.0.tar.gz", hash = "sha256:c402212a981a8a28615fea9120d789e3f6062c0c259a82bfb8dff5d273e539d2", size = 1948784, upload-time = "2025-12-29T18:06:00.659Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/5a/26cdb1b10a55ac6eb11a738cea14865fa753606c4897d7be0f5dc230df00/faker-39.0.0-py3-none-any.whl", hash = "sha256:c72f1fca8f1a24b8da10fcaa45739135a19772218ddd61b86b7ea1b8c790dce7", size = 1980775, upload-time = "2025-12-17T19:19:02.926Z" }, { url = "https://files.pythonhosted.org/packages/fc/23/e22da510e1ec1488966330bf76d8ff4bd535cbfc93660eeb7657761a1bb2/faker-40.1.0-py3-none-any.whl", hash = "sha256:a616d35818e2a2387c297de80e2288083bc915e24b7e39d2fb5bc66cce3a929f", size = 1985317, upload-time = "2025-12-29T18:05:58.831Z" },
] ]
[[package]] [[package]]
@@ -2456,15 +2456,15 @@ wheels = [
[[package]] [[package]]
name = "pdfminer-six" name = "pdfminer-six"
version = "20251228" version = "20251230"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "charset-normalizer" }, { name = "charset-normalizer" },
{ name = "cryptography" }, { name = "cryptography" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/16/65/1ea9a0a4b0bf0e711b5ec40ec4478a3dc597955a81bafdb46ed657f88bc5/pdfminer_six-20251228.tar.gz", hash = "sha256:5972b2babc5dd576a58634023b47e41ee827b505e36793cbfe37ac899000a1fb", size = 7391349, upload-time = "2025-12-28T14:32:26.76Z" } sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/aa/4ec00440997c382093b492e50eb117e134dd739eb6b9a1d752e68bbd3072/pdfminer_six-20251228-py3-none-any.whl", hash = "sha256:d365fb6dc41c5b8d04bd63622a3f468be24f3fecf54107e36e2451cf1bde870a", size = 5622067, upload-time = "2025-12-28T14:32:24.762Z" }, { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
] ]
[[package]] [[package]]