diff --git a/roboco/api/app.py b/roboco/api/app.py new file mode 100644 index 0000000..280dac9 --- /dev/null +++ b/roboco/api/app.py @@ -0,0 +1,455 @@ +""" +FastAPI Application Factory + +Creates and configures the FastAPI application with all routes, +middleware, and event handlers. +""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from roboco.api.deps import _auth_required +from roboco.api.middleware import setup_middleware +from roboco.api.routes.a2a import router as a2a_router +from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router +from roboco.api.routes.agents import router as agents_router +from roboco.api.routes.channels import router as channels_router +from roboco.api.routes.cockpit import router as cockpit_router +from roboco.api.routes.company_goals import router as company_goals_router +from roboco.api.routes.dashboard import router as dashboard_router +from roboco.api.routes.docs import router as docs_router +from roboco.api.routes.git import router as git_router +from roboco.api.routes.groups import router as groups_router +from roboco.api.routes.health import router as health_router +from roboco.api.routes.journals import router as journals_router +from roboco.api.routes.kanban import router as kanban_router +from roboco.api.routes.messages import router as messages_router +from roboco.api.routes.notifications import router as notifications_router +from roboco.api.routes.optimal import router as optimal_router +from roboco.api.routes.orchestrator import router as orchestrator_router +from roboco.api.routes.pitch import router as pitch_router +from roboco.api.routes.product import router as product_router +from roboco.api.routes.project import router as project_router +from roboco.api.routes.prompter_live import router as prompter_live_router +from roboco.api.routes.provider import router as provider_router +from roboco.api.routes.research import router as research_router +from roboco.api.routes.secretary import router as secretary_router +from roboco.api.routes.secretary_live import router as secretary_live_router +from roboco.api.routes.sessions import router as sessions_router +from roboco.api.routes.settings import router as settings_router +from roboco.api.routes.stream import router as stream_router +from roboco.api.routes.system import router as system_router +from roboco.api.routes.tasks import router as tasks_router +from roboco.api.routes.usage import router as usage_router +from roboco.api.routes.v1 import do as do_module +from roboco.api.routes.v1 import flow_auditor as flow_auditor_module +from roboco.api.routes.v1 import flow_board as flow_board_module +from roboco.api.routes.v1 import flow_cell_pm as flow_cell_pm_module +from roboco.api.routes.v1 import flow_dev as flow_dev_module +from roboco.api.routes.v1 import flow_doc as flow_doc_module +from roboco.api.routes.v1 import flow_main_pm as flow_main_pm_module +from roboco.api.routes.v1 import flow_pr_reviewer as flow_pr_reviewer_module +from roboco.api.routes.v1 import flow_qa as flow_qa_module +from roboco.api.routes.work_session import router as work_session_router +from roboco.api.websocket import router as ws_router +from roboco.config import settings +from roboco.db.base import close_db, get_session_factory, init_db +from roboco.logging import get_logger, setup_logging +from roboco.services.extraction import ExtractionPipeline, ExtractionService +from roboco.services.learning import get_learning_service +from roboco.services.optimal import close_optimal_service, get_optimal_service +from roboco.services.settings import apply_persisted_feature_flags +from roboco.services.transcription import TranscriptionService + +# Setup logging before anything else +setup_logging() +logger = get_logger(__name__) + + +class _AppServices: + """Holder for application service instances (initialized in lifespan).""" + + transcription: TranscriptionService | None = None + extraction: ExtractionPipeline | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + """ + Application lifespan manager. + + Handles startup and shutdown events. + """ + logger.info( + "Starting RoboCo API", + version=settings.app_version, + environment=settings.environment, + ) + + if not _auth_required(): + logger.warning( + "Agent auth is in HEADER-TRUST mode (ROBOCO_AGENT_AUTH_REQUIRED is " + "not set to true): the API accepts X-Agent-Id / X-Agent-Role without " + "verifying a signed token, so any client that can reach it may act as " + "any role, including 'ceo'. Acceptable only on a trusted private " + "network. Set ROBOCO_AGENT_AUTH_REQUIRED=true and do NOT expose this " + "API to untrusted networks.", + ) + + # Startup: apply Alembic migrations (+ create_all fallback for fresh DBs). + # init_db runs on every environment now — migrations are idempotent via + # alembic_version, and this is the only way new schema (e.g. enum value + # additions like NotificationType.APPROVAL) reaches the running DB. + await init_db() + logger.info("Database initialized") + + # Overlay panel-persisted feature-flag overrides onto the live config so the + # rest of startup (and the dispatch loops) read the panel's choices; unset + # flags keep their env/config default. Best-effort — a failure here must not + # block startup, the env defaults still apply. + try: + async with get_session_factory()() as _flags_db: + applied_flags = await apply_persisted_feature_flags(_flags_db) + if applied_flags: + logger.info("Applied persisted feature-flag overrides", flags=applied_flags) + except Exception as e: + logger.warning("Feature-flag overlay failed; using env defaults", error=str(e)) + + # Initialize Phase 2 services + _AppServices.transcription = TranscriptionService() + await _AppServices.transcription.start() + + extraction_service = ExtractionService() + _AppServices.extraction = ExtractionPipeline(extraction_service) + + # Store in app state for access in routes + app.state.transcription = _AppServices.transcription + app.state.extraction = _AppServices.extraction + + # Initialize OptimalService (RAG) - BLOCKS until fully ready + # This ensures /health only returns 200 when RAG is operational + # Typical initialization time: 30-90 seconds (embedding + indexing) + try: + logger.info("Initializing OptimalService (RAG)...") + optimal_service = await get_optimal_service() + app.state.optimal = optimal_service + logger.info("OptimalService (RAG) initialized successfully") + except Exception as e: + logger.warning( + "OptimalService (RAG) initialization failed - RAG features disabled", + error=str(e), + ) + app.state.optimal = None + + # Wire the learning-propagation singleton to OptimalService. Without this, + # record_learning() raises "not initialized" and every task completion logs + # "Failed to extract learnings". Skipped when RAG is disabled (no optimal). + if app.state.optimal is not None: + try: + learning_service = await get_learning_service() + await learning_service.initialize(app.state.optimal) + logger.info("LearningPropagationService initialized") + except Exception as e: + logger.warning("LearningPropagationService init failed", error=str(e)) + + logger.info("All services initialized, API ready") + + yield + + # Shutdown + logger.info("Shutting down RoboCo API") + + if _AppServices.transcription: + await _AppServices.transcription.stop() + + # Close Phase 3 services + await close_optimal_service() + + await close_db() + + logger.info("Shutdown complete") + + +def create_app() -> FastAPI: + """ + Create and configure the FastAPI application. + + Returns: + Configured FastAPI application instance. + """ + app = FastAPI( + title="RoboCo API", + description="AI Agents Company - Messaging and Task Management API", + version=settings.app_version, + docs_url="/docs", # if settings.debug else None, + redoc_url="/redoc", # if settings.debug else None, + lifespan=lifespan, + ) + + # ========================================================================== + # Middleware + # ========================================================================== + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=settings.cors_allow_credentials, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Setup custom middleware (error handling, logging, correlation IDs) + setup_middleware(app) + + # ========================================================================== + # Routes + # ========================================================================== + + # Health check + app.include_router(health_router, tags=["Health"]) + + # A2A Protocol: Well-known endpoints at root level + # (/.well-known/agent.json, /agents/{id}/.well-known/agent.json) + app.include_router(a2a_wellknown_router, tags=["A2A Protocol"]) + + # API v1 + api_prefix = "/api" + + app.include_router( + agents_router, + prefix=f"{api_prefix}/agents", + tags=["Agents"], + ) + + app.include_router( + channels_router, + prefix=f"{api_prefix}/channels", + tags=["Channels"], + ) + + app.include_router( + groups_router, + prefix=f"{api_prefix}/groups", + tags=["Groups"], + ) + + app.include_router( + sessions_router, + prefix=f"{api_prefix}/sessions", + tags=["Sessions"], + ) + + app.include_router( + settings_router, + prefix=f"{api_prefix}/settings", + tags=["Settings"], + ) + + app.include_router( + company_goals_router, + prefix=f"{api_prefix}/company-goals", + tags=["Company"], + ) + + app.include_router( + messages_router, + prefix=f"{api_prefix}/messages", + tags=["Messages"], + ) + + app.include_router( + notifications_router, + prefix=f"{api_prefix}/notifications", + tags=["Notifications"], + ) + + # Phase 2: Stream processing and permissions + app.include_router( + stream_router, + prefix=f"{api_prefix}/stream", + tags=["Stream Processing"], + ) + + # Phase 3: Intelligence - Optimal API and Journal API + app.include_router( + optimal_router, + prefix=f"{api_prefix}/optimal", + tags=["Optimal API"], + ) + + app.include_router( + journals_router, + prefix=f"{api_prefix}/journals", + tags=["Journals"], + ) + + # Web research — pluggable external search/fetch for Board + PM agents. + app.include_router( + research_router, + prefix=f"{api_prefix}/research", + tags=["Research"], + ) + + # Cockpit — the CEO's read-only "is the business winning?" summary. + app.include_router( + cockpit_router, + prefix=f"{api_prefix}/cockpit", + tags=["Cockpit"], + ) + + # Pitches — Board proposals + CEO approve -> auto-provision origination path. + app.include_router( + pitch_router, + prefix=f"{api_prefix}/pitches", + tags=["Pitches"], + ) + + # Secretary — the CEO's chief-of-staff: company-state reads + gated directives. + app.include_router( + secretary_router, + prefix=f"{api_prefix}/secretary", + tags=["Secretary"], + ) + # Secretary live chat — panel <-> Secretary container bridge. + app.include_router( + secretary_live_router, + prefix=f"{api_prefix}/secretary", + tags=["Secretary"], + ) + + # Phase 5: Management - Tasks, Kanban, Dashboards + app.include_router( + tasks_router, + prefix=f"{api_prefix}/tasks", + tags=["Tasks"], + ) + + app.include_router( + kanban_router, + prefix=f"{api_prefix}/kanban", + tags=["Kanban"], + ) + + app.include_router( + dashboard_router, + prefix=f"{api_prefix}/dashboard", + tags=["Dashboard"], + ) + + # Phase 7: Agent Runtime + app.include_router( + orchestrator_router, + prefix=f"{api_prefix}/orchestrator", + tags=["Orchestrator"], + ) + + # A2A Protocol: API endpoints + app.include_router( + a2a_router, + prefix=f"{api_prefix}/a2a", + tags=["A2A Protocol"], + ) + + # Git Integration + app.include_router( + git_router, + prefix=f"{api_prefix}/git", + tags=["Git Operations"], + ) + + # Project Management + app.include_router( + project_router, + prefix=f"{api_prefix}/projects", + tags=["Projects"], + ) + + # Product Management + app.include_router( + product_router, + prefix=f"{api_prefix}/products", + tags=["Products"], + ) + + # AI Providers (model routing + Ollama-cloud fallback) + app.include_router( + provider_router, + prefix=f"{api_prefix}/providers", + tags=["Providers"], + ) + + # Prompter live chat — panel <-> spawned intake agent (SSE + relay) + app.include_router( + prompter_live_router, + prefix=f"{api_prefix}/prompter", + tags=["Prompter"], + ) + + # Work Sessions + app.include_router( + work_session_router, + prefix=f"{api_prefix}/work-sessions", + tags=["Work Sessions"], + ) + + # Documentation + app.include_router( + docs_router, + prefix=f"{api_prefix}/docs", + tags=["Documentation"], + ) + + # Token Usage Analytics + app.include_router( + usage_router, + prefix=f"{api_prefix}/usage", + tags=["Usage Analytics"], + ) + + # System monitoring (rate-limits, etc.) + app.include_router( + system_router, + prefix=f"{api_prefix}/system", + tags=["System"], + ) + + # API v1 — intent-verb flow endpoints + app.include_router(flow_dev_module.router) + + # API v1 — intent-verb QA flow endpoints + app.include_router(flow_qa_module.router) + + # API v1 — intent-verb documenter flow endpoints + app.include_router(flow_doc_module.router) + + # API v1 — intent-verb cell PM flow endpoints + app.include_router(flow_cell_pm_module.router) + + # API v1 — intent-verb main PM flow endpoints + app.include_router(flow_main_pm_module.router) + + # API v1 — intent-verb board flow endpoints + app.include_router(flow_board_module.router) + + # API v1 — intent-verb auditor flow endpoints + app.include_router(flow_auditor_module.router) + + # API v1 — intent-verb PR-reviewer flow endpoints + app.include_router(flow_pr_reviewer_module.router) + + # API v1 — content-tool endpoints + app.include_router(do_module.router) + + # ========================================================================== + # WebSocket + # ========================================================================== + app.include_router(ws_router, prefix="/ws", tags=["WebSocket"]) + + return app + + +# Create the default application instance +app = create_app() diff --git a/roboco/api/deps.py b/roboco/api/deps.py new file mode 100644 index 0000000..65e382a --- /dev/null +++ b/roboco/api/deps.py @@ -0,0 +1,568 @@ +""" +API Dependencies + +Shared dependencies for FastAPI routes. +""" + +from __future__ import annotations + +import contextlib +import os +from typing import TYPE_CHECKING, Annotated, Any +from uuid import UUID + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from roboco.agents_config import verify_agent_token +from roboco.api.schemas.optimal import PaginationParams +from roboco.db.base import get_db +from roboco.db.tables import AgentTable +from roboco.foundation.identity import BOARD_ROLES, DEV_ROLES, PM_ROLES, Role +from roboco.models import AgentRole, Team +from roboco.runtime import AgentOrchestrator +from roboco.services.a2a import A2AService +from roboco.services.audit import get_audit_service +from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps +from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps +from roboco.services.gateway.evidence_repo import EvidenceRepo +from roboco.services.git import GitService +from roboco.services.journal import JournalService +from roboco.services.messaging import MessagingService +from roboco.services.notification import NotificationService +from roboco.services.notification_delivery import NotificationDeliveryService +from roboco.services.permissions import AgentContext, PermissionService +from roboco.services.product import ProductService +from roboco.services.repositories import resolve_agent_identity, resolve_agent_uuid +from roboco.services.task import TaskService +from roboco.services.work_session import WorkSessionService +from roboco.services.workspace import WorkspaceService + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + +# Type alias for database session dependency +DbSession = Annotated[AsyncSession, Depends(get_db)] + + +async def resolve_agent_id(agent_id_str: str, db: AsyncSession) -> UUID: + """ + Resolve agent ID from string (UUID or slug). + + Args: + agent_id_str: Either a UUID string or agent slug (e.g., "be-dev-1") + db: Database session + + Returns: + UUID of the agent + + Raises: + HTTPException: If agent not found or invalid format + """ + result = await resolve_agent_uuid(db, agent_id_str) + + if result is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Agent not found: {agent_id_str}", + ) + + return result + + +class _ServiceHolder: + """Holder for singleton service instances.""" + + permission_service: PermissionService | None = None + orchestrator: AgentOrchestrator | None = None + + +def get_permission_service() -> PermissionService: + """Get or create the permission service singleton.""" + if _ServiceHolder.permission_service is None: + _ServiceHolder.permission_service = PermissionService() + return _ServiceHolder.permission_service + + +PermissionServiceDep = Annotated[PermissionService, Depends(get_permission_service)] + + +def set_orchestrator(orchestrator: AgentOrchestrator) -> None: + """Set the global orchestrator instance.""" + _ServiceHolder.orchestrator = orchestrator + + +def get_orchestrator() -> AgentOrchestrator: + """Get the global orchestrator instance.""" + if _ServiceHolder.orchestrator is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Orchestrator not initialized", + ) + return _ServiceHolder.orchestrator + + +OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)] + + +async def get_current_agent_id( + db: DbSession, + x_agent_id: Annotated[str | None, Header()] = None, +) -> UUID: + """ + Get the current agent ID from request headers. + + Accepts either a UUID string or agent slug (e.g., "be-dev-1"). + In production, this would validate a JWT token and extract the agent ID. + For now, we use a simple header-based approach for development. + + Args: + x_agent_id: Agent ID (UUID or slug) from X-Agent-ID header + db: Database session for slug resolution + + Returns: + UUID of the current agent + + Raises: + HTTPException: If agent ID is missing or invalid/not found + """ + if not x_agent_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing X-Agent-ID header", + ) + + return await resolve_agent_id(x_agent_id, db) + + +# Type alias for current agent dependency +CurrentAgentId = Annotated[UUID, Depends(get_current_agent_id)] + + +async def get_current_agent_slug( + x_agent_id: Annotated[str | None, Header()] = None, +) -> str: + """ + Get the current agent slug from request headers. + + Unlike get_current_agent_id, this returns the slug directly without + resolving to UUID. Useful for A2A where we work with agent slugs. + + Args: + x_agent_id: Agent slug from X-Agent-ID header + + Returns: + Agent slug string + + Raises: + HTTPException: If agent ID header is missing + """ + if not x_agent_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing X-Agent-ID header", + ) + return x_agent_id + + +# Type alias for agent slug dependency +CurrentAgentSlug = Annotated[str, Depends(get_current_agent_slug)] + + +async def get_optional_agent_id( + db: DbSession, + x_agent_id: Annotated[str | None, Header()] = None, +) -> UUID | None: + """ + Get the current agent ID if provided. + + Accepts either a UUID string or agent slug (e.g., "be-dev-1"). + Unlike get_current_agent_id, this doesn't raise an error if missing. + """ + if not x_agent_id: + return None + + try: + return await resolve_agent_id(x_agent_id, db) + except HTTPException: + return None + + +OptionalAgentId = Annotated[UUID | None, Depends(get_optional_agent_id)] + + +def _auth_required() -> bool: + """True when agent HMAC auth is mandatory (prod-ish) vs opt-in (dev).""" + val = os.environ.get("ROBOCO_AGENT_AUTH_REQUIRED", "").strip().lower() + return val in ("1", "true", "yes") + + +def _check_agent_auth_token( + x_agent_id: str, + x_agent_role: str, + x_agent_team: str | None, + x_agent_token: str | None, +) -> None: + """Enforce HMAC token when required; reject invalid tokens even in dev.""" + # Token verification: stops an agent on the Docker network from + # spoofing another agent's role by setting headers directly. When + # ROBOCO_AGENT_AUTH_REQUIRED is true, every request must carry a + # token matching HMAC(id:role:team, secret). In dev it's optional + # (so the panel / curl-for-debugging keep working), but any token + # that IS presented is still verified — you can't bypass by + # supplying an invalid token. + if _auth_required() and not x_agent_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing X-Agent-Token header (auth required)", + ) + if x_agent_token and not verify_agent_token( + x_agent_token, + x_agent_id, + x_agent_role, + x_agent_team or "", + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=( + "Invalid X-Agent-Token — signature mismatch. Header " + "values do not match the token issued for this agent." + ), + ) + + +async def _resolve_agent_identity( + db: DbSession, x_agent_id: str, x_agent_role: str +) -> tuple[UUID, str]: + """Return (agent_id, slug), handling the special `system` role.""" + if x_agent_role.lower() == "system": + try: + return UUID(x_agent_id), "system" + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid system agent UUID: {x_agent_id}", + ) from e + identity = await resolve_agent_identity(db, x_agent_id) + if identity is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Agent not found: {x_agent_id}", + ) + return identity + + +async def _coerce_agent_role( + db: DbSession, x_agent_role: str, agent_id: UUID, x_agent_id: str +) -> AgentRole: + """Parse the role header; fall back to the DB role if it's a slug.""" + try: + return AgentRole(x_agent_role.lower()) + except ValueError: + # Panel/clients sometimes pass the agent slug (e.g. "main-pm") instead + # of the role value ("main_pm"). If the header isn't a valid enum + # value, fall back to the authoritative role on the agent row we + # already resolved above. + role_row = await db.execute( + select(AgentTable.role).where(AgentTable.id == agent_id) + ) + db_role = role_row.scalar_one_or_none() + if db_role is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Invalid agent role '{x_agent_role}' and no role on " + f"record for agent {x_agent_id}" + ), + ) from None + return db_role + + +def _coerce_agent_team(x_agent_team: str | None) -> Team | None: + """Parse the team header; return None for empty/invalid values.""" + if not x_agent_team: + return None + with contextlib.suppress(ValueError): + return Team(x_agent_team.lower()) + return None + + +async def get_agent_context( + db: DbSession, + x_agent_id: Annotated[str | None, Header()] = None, + x_agent_role: Annotated[str | None, Header()] = None, + x_agent_team: Annotated[str | None, Header()] = None, + x_agent_token: Annotated[str | None, Header()] = None, +) -> AgentContext: + """ + Get the current agent context from request headers. + + Headers: + X-Agent-ID: UUID or slug of the agent (e.g., "be-dev-1") + X-Agent-Role: Role (e.g., 'developer', 'cell_pm') + X-Agent-Team: (optional) Team (e.g., 'backend', 'frontend') + X-Agent-Token: (required when ROBOCO_AGENT_AUTH_REQUIRED=true) + HMAC of "agent_id:role:team" signed with + ROBOCO_AGENT_AUTH_SECRET. Orchestrator issues this at spawn. + """ + if not x_agent_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing X-Agent-ID header", + ) + if not x_agent_role: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing X-Agent-Role header", + ) + + _check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token) + + agent_id, slug = await _resolve_agent_identity(db, x_agent_id, x_agent_role) + role = await _coerce_agent_role(db, x_agent_role, agent_id, x_agent_id) + team = _coerce_agent_team(x_agent_team) + + return AgentContext( + agent_id=agent_id, + role=role, + team=team, + slug=slug, + ) + + +CurrentAgentContext = Annotated[AgentContext, Depends(get_agent_context)] + + +# ============================================================================= +# ROLE-GATE HELPERS +# +# Small HTTP-layer guards for routes that need a coarse "PM or above" / +# "developer or above" check. They raise HTTPException directly because +# the check IS the HTTP authorization decision — no service-side logic, +# no translation layer needed. +# ============================================================================= + +# Role-sets derive from foundation so renaming a role lives in one file. +# HEAD_MARKETING is intentionally excluded from every "above" set — the role is +# a marketing spokesperson, not a workflow approver. StrEnum membership means +# the sets compare equal against both Role.* and the lowercase header string. +_PM_OR_ABOVE_ROLES: frozenset[Role] = ( + PM_ROLES | (BOARD_ROLES - {Role.HEAD_MARKETING}) | {Role.CEO} +) +_DEVELOPER_OR_ABOVE_ROLES: frozenset[Role] = DEV_ROLES | _PM_OR_ABOVE_ROLES + + +def _role_value(role: Any) -> str: + """AgentRole or str → plain string for set membership checks.""" + return role.value if hasattr(role, "value") else str(role) + + +def require_pm_or_above(role: Any, action: str) -> None: + """Raise 403 unless caller is PM-or-above (cell_pm/main_pm/board/CEO).""" + if _role_value(role) not in _PM_OR_ABOVE_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Only PMs and management can {action}", + ) + + +def require_developer_or_above(role: Any, action: str) -> None: + """Raise 403 unless caller is developer-or-above.""" + if _role_value(role) not in _DEVELOPER_OR_ABOVE_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Only developers and above can {action}", + ) + + +_GLOBAL_CELL_ACCESS_ROLES: frozenset[Role] = (BOARD_ROLES - {Role.HEAD_MARKETING}) | { + Role.MAIN_PM, + Role.CEO, +} + + +def require_cell_access(agent: AgentContext, cell: Team, action: str) -> None: + """Raise 403 unless caller can act in the given cell. + + Main PM, board, and CEO can act across all cells. Cell PMs and their + members are restricted to their own cell. + """ + if _role_value(agent.role) in _GLOBAL_CELL_ACCESS_ROLES: + return + if agent.team != cell: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Cannot {action} projects in {cell.value} cell", + ) + + +def require_channel_read( + channel_name: str, +) -> Callable[..., Coroutine[Any, Any, None]]: + """ + Dependency factory that requires read access to a channel. + + Usage: + @router.get("/channels/{channel_id}/messages") + async def get_messages( + agent: CurrentAgentContext, + _: Annotated[None, Depends(require_channel_read("backend-cell"))], + ): + ... + """ + + async def check_permission( + agent: CurrentAgentContext, + permissions: PermissionServiceDep, + ) -> None: + if not permissions.can_read_channel(agent, channel_name): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"No read access to channel: {channel_name}", + ) + + return check_permission + + +def require_channel_write( + channel_name: str, +) -> Callable[..., Coroutine[Any, Any, None]]: + """ + Dependency factory that requires write access to a channel. + """ + + async def check_permission( + agent: CurrentAgentContext, + permissions: PermissionServiceDep, + ) -> None: + if not permissions.can_write_channel(agent, channel_name): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"No write access to channel: {channel_name}", + ) + + return check_permission + + +def require_notification_permission() -> Callable[..., Coroutine[Any, Any, None]]: + """ + Dependency that requires the agent can send notifications. + """ + + async def check_permission( + agent: CurrentAgentContext, + permissions: PermissionServiceDep, + ) -> None: + if not permissions.can_send_notifications(agent): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to send notifications", + ) + + return check_permission + + +def require_task_action( + action: str, task_team: Team | None = None +) -> Callable[..., Coroutine[Any, Any, None]]: + """ + Dependency factory that requires permission for a task action. + + Args: + action: The task action (from TaskAction constants) + task_team: Optional team context for team-specific checks + + Usage: + @router.post("/tasks") + async def create_task( + agent: CurrentAgentContext, + _: Annotated[None, Depends(require_task_action("create"))], + ): + ... + """ + + async def check_permission( + agent: CurrentAgentContext, + permissions: PermissionServiceDep, + ) -> None: + if not permissions.can_perform_task_action(agent, action, task_team): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Not authorized to perform task action: {action}", + ) + + return check_permission + + +# ============================================================================= +# GATEWAY / CHOREOGRAPHER DEPENDENCIES +# ============================================================================= + + +async def get_choreographer( + db_session: DbSession, +) -> Choreographer: + """Build a Choreographer with all service dependencies wired up.""" + from roboco.events.stream_bus import get_stream_event_bus + + # Inject the orchestrator (if initialised) and the stream event bus + # so the rate-limited i_am_blocked path can park agents and publish events. + # Both are None-safe in ChoreographerDeps — passing None is the same as + # omitting the field, so the choreographer degrades gracefully when the + # orchestrator has not been initialised yet (e.g. during startup). + orch: AgentOrchestrator | None = _ServiceHolder.orchestrator + bus = get_stream_event_bus() if _ServiceHolder.orchestrator is not None else None + return Choreographer( + ChoreographerDeps( + task=TaskService(db_session), + work_session=WorkSessionService(db_session), + git=GitService(db_session), + a2a=A2AService(db_session), + journal=JournalService(db_session), + audit=get_audit_service(), + evidence_repo=EvidenceRepo(db_session), + messaging=MessagingService(db_session), + product=ProductService(db_session), + orchestrator=orch, + stream_bus=bus, + ) + ) + + +async def get_content_actions( + db_session: DbSession, +) -> ContentActions: + """Build a ContentActions with all service dependencies wired up.""" + return ContentActions( + ContentActionsDeps( + task=TaskService(db_session), + git=GitService(db_session), + messaging=MessagingService(db_session), + a2a=A2AService(db_session), + journal=JournalService(db_session), + workspace=WorkspaceService(db_session), + notifications=NotificationService(), + notification_delivery=NotificationDeliveryService(db_session), + evidence_repo=EvidenceRepo(db_session), + ) + ) + + +# ============================================================================= +# PAGINATION DEPENDENCIES +# ============================================================================= + + +def get_pagination( + limit: int = 50, + offset: int = 0, +) -> PaginationParams: + """Dependency for pagination parameters.""" + # Enforce constraints + limit = max(1, min(100, limit)) + offset = max(0, offset) + return PaginationParams(limit=limit, offset=offset) + + +PaginationDep = Annotated[PaginationParams, Depends(get_pagination)] diff --git a/roboco/api/middleware.py b/roboco/api/middleware.py new file mode 100644 index 0000000..eb69885 --- /dev/null +++ b/roboco/api/middleware.py @@ -0,0 +1,421 @@ +""" +API Middleware + +Request/response middleware for logging, error handling, and correlation IDs. +""" + +import time +import uuid +from collections.abc import Callable, Sequence +from typing import Any, cast + +import structlog +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi import status as http_status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from roboco.api.schemas.common import ErrorCode +from roboco.exceptions import ( + AuthenticationError, + InvalidStateError, + NotFoundError, + PermissionDeniedError, + RobocoError, + ValidationError, +) +from roboco.services.base import ( + ConflictError as ServiceConflictError, +) +from roboco.services.base import ( + NotFoundError as ServiceNotFoundError, +) +from roboco.services.base import ( + ServiceError, + ServiceUnavailableError, +) +from roboco.services.base import ( + UnauthorizedError as ServiceUnauthorizedError, +) +from roboco.services.base import ( + ValidationError as ServiceValidationError, +) +from roboco.services.exceptions import RateLimitError + +logger = structlog.get_logger() + + +# ============================================================================= +# CORRELATION ID MIDDLEWARE +# ============================================================================= + + +class CorrelationIdMiddleware(BaseHTTPMiddleware): + """ + Adds a correlation ID to each request for tracing. + + The correlation ID is: + - Extracted from X-Correlation-ID header if present + - Generated if not present + - Added to response headers + - Bound to the logger context + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Get or generate correlation ID + correlation_id = request.headers.get("X-Correlation-ID") + if not correlation_id: + correlation_id = str(uuid.uuid4()) + + # Store in request state for access in handlers + request.state.correlation_id = correlation_id + + # Bind to structlog context + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars( + correlation_id=correlation_id, + path=request.url.path, + method=request.method, + ) + + # Process request + response = cast("Response", await call_next(request)) + + # Add correlation ID to response + response.headers["X-Correlation-ID"] = correlation_id + + return response + + +# ============================================================================= +# REQUEST LOGGING MIDDLEWARE +# ============================================================================= + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """ + Logs request/response details with timing. + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + start_time = time.perf_counter() + + # Log request + logger.info( + "Request started", + path=request.url.path, + method=request.method, + query_params=dict(request.query_params), + ) + + try: + response = cast("Response", await call_next(request)) + duration_ms = (time.perf_counter() - start_time) * 1000 + + # Log response + logger.info( + "Request completed", + status_code=response.status_code, + duration_ms=round(duration_ms, 2), + ) + + # Add timing header + response.headers["X-Response-Time-Ms"] = str(round(duration_ms, 2)) + + return response + + except Exception as e: + duration_ms = (time.perf_counter() - start_time) * 1000 + logger.exception( + "Request failed", + duration_ms=round(duration_ms, 2), + error=str(e), + ) + raise + + +# ============================================================================= +# EXCEPTION HANDLERS +# ============================================================================= + + +def get_status_code(exc: RobocoError) -> int: + """Map exception type to HTTP status code.""" + status_map = { + NotFoundError: 404, + ValidationError: 422, + InvalidStateError: 409, + PermissionDeniedError: 403, + AuthenticationError: 401, + } + + for exc_type, status in status_map.items(): + if isinstance(exc, exc_type): + return status + + # Default for other RobocoError subclasses + return 400 + + +async def roboco_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Handle RobocoError exceptions.""" + roboco_exc = cast("RobocoError", exc) + status_code = get_status_code(roboco_exc) + + # Add correlation ID to error details + correlation_id = getattr(request.state, "correlation_id", None) + if correlation_id: + roboco_exc.details["correlation_id"] = correlation_id + + logger.warning( + "Handled exception", + error_code=roboco_exc.code, + error_message=roboco_exc.message, + status_code=status_code, + ) + + return JSONResponse( + status_code=status_code, + content=roboco_exc.to_dict(), + ) + + +# `roboco.services.base.ServiceError` is a parallel exception hierarchy that +# does NOT inherit from `RobocoError` (it extends `Exception` directly), so +# `roboco_exception_handler` never sees it and the requests fall through to +# `generic_exception_handler` as 500s. Map its subclasses to the same status +# codes used in the RobocoError handler so route-layer try/except blocks can +# surface clean 4xx codes whether the service raises from `roboco.exceptions` +# or `roboco.services.base`. +_SERVICE_ERROR_STATUS: dict[type[ServiceError], int] = { + ServiceNotFoundError: 404, + ServiceValidationError: 422, + ServiceConflictError: 409, + ServiceUnauthorizedError: 403, + ServiceUnavailableError: 503, +} + + +async def service_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Handle `roboco.services.base.ServiceError` and subclasses.""" + svc_exc = cast("ServiceError", exc) + status_code = 500 + for exc_type, mapped_status in _SERVICE_ERROR_STATUS.items(): + if isinstance(svc_exc, exc_type): + status_code = mapped_status + break + + correlation_id = getattr(request.state, "correlation_id", None) + details = dict(svc_exc.details) + if correlation_id: + details["correlation_id"] = correlation_id + + logger.warning( + "Handled exception", + error_type=type(svc_exc).__name__, + error_message=svc_exc.message, + status_code=status_code, + ) + + return JSONResponse( + status_code=status_code, + content={ + "error": type(svc_exc).__name__, + "message": svc_exc.message, + "details": details, + }, + ) + + +async def rate_limit_exception_handler( + request: Request, exc: Exception +) -> JSONResponse: + """Handle :class:`~roboco.services.exceptions.RateLimitError`. + + Returns HTTP 429 with a ``Retry-After`` response header (when available) + and a structured JSON body so API consumers can back off gracefully. + """ + rl_exc = cast("RateLimitError", exc) + correlation_id = getattr(request.state, "correlation_id", None) + + logger.warning( + "LLM rate limit exhausted", + provider=rl_exc.provider, + retry_after=rl_exc.retry_after, + ) + + content: dict = { + "error": "rate_limit_exceeded", + "provider": rl_exc.provider, + "message": str(rl_exc), + } + if correlation_id: + content["correlation_id"] = correlation_id + + headers: dict[str, str] = {} + if rl_exc.retry_after is not None: + headers["Retry-After"] = str(int(rl_exc.retry_after)) + + return JSONResponse( + status_code=429, + content=content, + headers=headers, + ) + + +async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Handle unexpected exceptions.""" + correlation_id = getattr(request.state, "correlation_id", None) + + logger.exception( + "Unhandled exception", + error=str(exc), + error_type=type(exc).__name__, + ) + + return JSONResponse( + status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": { + "code": ErrorCode.INTERNAL_ERROR, + "message": "An internal error occurred", + "details": { + "correlation_id": correlation_id, + }, + } + }, + ) + + +# Map HTTP status codes to string error codes +_HTTP_TO_ERROR_CODE: dict[int, str] = { + 400: ErrorCode.INVALID_INPUT, + 401: ErrorCode.NOT_AUTHORIZED, + 403: ErrorCode.ACCESS_DENIED, + 404: ErrorCode.NOT_FOUND, + 409: ErrorCode.INVALID_INPUT, # Conflict + 422: ErrorCode.INVALID_INPUT, # Validation error + 500: ErrorCode.INTERNAL_ERROR, +} + + +async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + Handle FastAPI HTTPException with standardized error format. + + Converts HTTP status codes to string error codes for consistency with MCP. + """ + http_exc = cast("HTTPException", exc) + correlation_id = getattr(request.state, "correlation_id", None) + + # Map status code to error code + error_code = _HTTP_TO_ERROR_CODE.get(http_exc.status_code, ErrorCode.INTERNAL_ERROR) + + logger.warning( + "HTTP exception", + status_code=http_exc.status_code, + error_code=error_code, + detail=http_exc.detail, + ) + + response_content: dict = { + "error": { + "code": error_code, + "message": str(http_exc.detail), + } + } + + if correlation_id: + response_content["error"]["details"] = {"correlation_id": correlation_id} + + return JSONResponse( + status_code=http_exc.status_code, + content=response_content, + ) + + +# ============================================================================= +# SETUP FUNCTION +# ============================================================================= + + +def _uuid_field_remediation(errors: Sequence[Any]) -> str | None: + """Spell out the fix when a truncated id is sent where a UUID is required. + + Agents routinely copy the 8-character task prefix the system shows them + (e.g. the ``[cee99ecc]`` commit prefix) and send it as ``task_id``, which + fails UUID validation with an opaque "invalid length" message and wastes a + call. Detect that case and hand back an actionable remediation instead. + """ + for err in errors: + if not isinstance(err, dict): + continue + loc = err.get("loc") or () + field = loc[-1] if loc else None + if field == "task_id" and "uuid" in str(err.get("type", "")).lower(): + return ( + "Use the FULL 36-character task UUID, not the 8-character short " + "form shown in commit prefixes or summaries. The full id is in " + "the `task_id` field of the envelope returned by give_me_work " + "or your most recent verb." + ) + return None + + +async def request_validation_handler(request: Request, exc: Exception) -> JSONResponse: + """Log the rejected body before returning the standard 422 response. + + FastAPI's default 422 returns validation details to the client but + nothing lands in server logs. During smoke tests this leaves us + blind to which field actually broke. Log the body + the per-field + errors so the next 422 is debuggable in one log scan. + + When the failure is a truncated ``task_id`` (the recurring agent mistake), + add a ``remediate`` hint so the agent knows to retry with the full UUID. + """ + rve = cast("RequestValidationError", exc) + body = rve.body if isinstance(rve.body, str | bytes | dict | list) else None + errors = rve.errors() + logger.warning( + "Request validation failed", + path=request.url.path, + method=request.method, + body=body, + errors=errors, + ) + content: dict[str, Any] = {"detail": errors, "body": body} + remediate = _uuid_field_remediation(errors) + if remediate is not None: + content["remediate"] = remediate + return JSONResponse( + status_code=http_status.HTTP_422_UNPROCESSABLE_CONTENT, + content=content, + ) + + +def setup_middleware(app: FastAPI) -> None: + """ + Setup all middleware for the application. + + Order matters: + 1. CorrelationIdMiddleware - first to set correlation ID + 2. RequestLoggingMiddleware - logs with correlation ID + + Exception handler priority: + 1. RequestValidationError - 422s; log body + per-field errors + 2. HTTPException - most common, converts to string error codes + 3. RobocoError - custom domain exceptions + 4. Exception - catch-all for unexpected errors + """ + # Exception handlers (order: specific to general) + app.add_exception_handler(RequestValidationError, request_validation_handler) + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(RobocoError, roboco_exception_handler) + app.add_exception_handler(ServiceError, service_exception_handler) + app.add_exception_handler(RateLimitError, rate_limit_exception_handler) + app.add_exception_handler(Exception, generic_exception_handler) + + # Middleware (added in reverse order due to LIFO) + app.add_middleware(RequestLoggingMiddleware) + app.add_middleware(CorrelationIdMiddleware) diff --git a/roboco/api/routes/git.py b/roboco/api/routes/git.py new file mode 100644 index 0000000..720a53e --- /dev/null +++ b/roboco/api/routes/git.py @@ -0,0 +1,627 @@ +""" +Git API Routes + +Git operations for agents working on code tasks. +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. +""" + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from roboco.api.deps import CurrentAgentContext, DbSession +from roboco.api.schemas.git import ( + BranchInfo, + CommitInfo, + GitBranchListResponse, + GitCheckoutRequest, + GitCheckoutResponse, + GitCommitRequest, + GitCommitResponse, + GitCreateBranchRequest, + GitCreateBranchResponse, + GitCreatePRRequest, + GitCreatePRResponse, + GitDiffResponse, + GitFetchRequest, + GitFetchResponse, + GitLogResponse, + GitMergePRRequest, + GitMergePRResponse, + GitPullRequest, + GitPullResponse, + GitPushRequest, + GitPushResponse, + GitRebaseRequest, + GitRebaseResponse, + GitStatusResponse, +) +from roboco.exceptions import GitCommandError, GitError, GitTimeoutError +from roboco.logging import get_logger +from roboco.models.base import AgentRole +from roboco.services.base import ( + NotFoundError, + ServiceError, + UnauthorizedError, + ValidationError, +) +from roboco.services.git import get_git_service +from roboco.services.project import get_project_service +from roboco.services.task import get_task_service + +logger = get_logger(__name__) + +router = APIRouter() + +# Expected number of parts in log format output +_LOG_FORMAT_PARTS = 5 + +# Catch tuple for service-layer errors. `roboco.exceptions.GitError` is a +# distinct class from `roboco.services.base.ServiceError` (it extends the +# `roboco.exceptions.ServiceError` class), so listing both is required for +# git timeouts/command failures to be translated to 504/500 instead of +# bubbling as 500 Internal Server Errors with no `detail`. +_TranslatableError = (ServiceError, GitError) + +# Roles permitted to rebase branches via the /rebase endpoint. +# Rebase is a history-rewriting operation that should be authorised only by +# PM-level or CEO-level callers. Developers are intentionally excluded: +# they commit to their feature branch and let PMs/CEO manage integration +# rebases. This gate prevents developers from accidentally force-rewriting +# shared branch history. +_REBASE_ALLOWED_ROLES: frozenset[AgentRole] = frozenset( + {AgentRole.CEO, AgentRole.CELL_PM, AgentRole.MAIN_PM} +) + + +def _translate_error(e: ServiceError | GitError) -> HTTPException: + """Translate service errors to HTTP exceptions.""" + if isinstance(e, NotFoundError): + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.message) + if isinstance(e, UnauthorizedError): + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) + if isinstance(e, ValidationError): + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.message) + if isinstance(e, GitTimeoutError): + return HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail=e.message + ) + if isinstance(e, GitCommandError): + return HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message + ) + return HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message + ) + + +async def _resolve_project_slug(identifier: str, db: AsyncSession) -> str: + """Resolve a project identifier (UUID string or slug) to its slug. + + Callers pass whatever string they have — a human-readable slug like + "roboco" or a UUID like "3fa85f64-5717-4562-b3fc-2c963f66afa6". + We try UUID first; if the string is not a valid UUID we treat it as + a slug directly. In both cases we verify the project exists and + return the canonical slug so downstream git-service calls work. + """ + service = get_project_service(db) + try: + uuid = UUID(identifier) + project = await service.get(uuid) + except ValueError: + project = await service.get_by_slug(identifier) + + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project not found: {identifier}", + ) + return str(project.slug) + + +# ============================================================================= +# READ-ONLY ENDPOINTS +# ============================================================================= + + +@router.get("/status", response_model=GitStatusResponse) +async def get_git_status( + db: DbSession, + agent: CurrentAgentContext, + project_slug: str = Query(...), + _task_id: str | None = Query(default=None), +) -> GitStatusResponse: + """Get git status for a project.""" + project_slug = await _resolve_project_slug(project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + ( + current_branch, + has_changes, + staged, + unstaged, + untracked, + ahead, + behind, + ) = await git_service.get_status(workspace) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitStatusResponse( + project_slug=project_slug, + current_branch=current_branch, + has_changes=has_changes, + staged_files=staged, + unstaged_files=unstaged, + untracked_files=untracked, + ahead=ahead, + behind=behind, + ) + + +@router.get("/log", response_model=GitLogResponse) +async def get_git_log( + db: DbSession, + agent: CurrentAgentContext, + project_slug: str = Query(...), + limit: int = Query(default=10, le=50), + branch: str | None = Query(default=None), +) -> GitLogResponse: + """Get git log for a project.""" + project_slug = await _resolve_project_slug(project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + + # Get current branch if not specified + if not branch: + branch = await git_service.get_current_branch(workspace) + + # Get log with format. Don't raise if the branch doesn't exist in + # this workspace yet — that's a normal race (branch created in a + # different agent's clone, not yet fetched here). Return empty. + log_format = "%H|%h|%s|%an|%aI" + log_result = await git_service._run_git( + workspace, + ["log", f"--format={log_format}", f"-n{limit}", branch], + check=False, + ) + if log_result.returncode != 0: + logger.info( + "git log on missing/unknown ref; returning empty", + project_slug=project_slug, + branch=branch, + stderr=log_result.stderr[:200] if log_result.stderr else "", + ) + return GitLogResponse(project_slug=project_slug, branch=branch, commits=[]) + except _TranslatableError as e: + raise _translate_error(e) from e + + commits = [] + for line in log_result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("|", 4) + if len(parts) == _LOG_FORMAT_PARTS: + commits.append( + CommitInfo( + hash=parts[0], + short_hash=parts[1], + message=parts[2], + author=parts[3], + date=datetime.fromisoformat(parts[4]), + ) + ) + + return GitLogResponse( + project_slug=project_slug, + branch=branch, + commits=commits, + ) + + +@router.get("/branches", response_model=GitBranchListResponse) +async def list_branches( + db: DbSession, + agent: CurrentAgentContext, + project_slug: str = Query(...), + include_remote: bool = Query(default=False), +) -> GitBranchListResponse: + """List git branches for a project.""" + project_slug = await _resolve_project_slug(project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + current_branch = await git_service.get_current_branch(workspace) + + # Get branches + args = ["branch", "--format=%(refname:short)|%(objectname:short)"] + if include_remote: + args.append("-a") + + branch_result = await git_service._run_git(workspace, args) + except _TranslatableError as e: + raise _translate_error(e) from e + + branches = [] + for line in branch_result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("|") + name = parts[0] + last_commit = parts[1] if len(parts) > 1 else None + + is_remote = name.startswith("remotes/") + if is_remote: + name = name.replace("remotes/origin/", "") + + branches.append( + BranchInfo( + name=name, + is_current=name == current_branch, + is_remote=is_remote, + last_commit=last_commit, + ) + ) + + return GitBranchListResponse( + project_slug=project_slug, + current_branch=current_branch, + branches=branches, + ) + + +@router.get("/diff", response_model=GitDiffResponse) +async def get_git_diff( + db: DbSession, + agent: CurrentAgentContext, + project_slug: str = Query(...), + staged: bool = Query(default=False), + file_path: str | None = Query(default=None), +) -> GitDiffResponse: + """Get git diff for a project.""" + project_slug = await _resolve_project_slug(project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + + args = ["diff"] + if staged: + args.append("--staged") + if file_path: + args.extend(["--", file_path]) + + diff_result = await git_service._run_git(workspace, args) + + # Count files changed + stat_args = ["diff", "--stat"] + if staged: + stat_args.append("--staged") + stat_result = await git_service._run_git(workspace, stat_args) + except _TranslatableError as e: + raise _translate_error(e) from e + + files_changed = stat_result.stdout.count("\n") - 1 if stat_result.stdout else 0 + + return GitDiffResponse( + project_slug=project_slug, + staged=staged, + file_path=file_path, + diff=diff_result.stdout, + files_changed=max(0, files_changed), + ) + + +# ============================================================================= +# WRITE ENDPOINTS +# ============================================================================= + + +@router.post("/commit", response_model=GitCommitResponse) +async def create_commit( + data: GitCommitRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitCommitResponse: + """Create a git commit and link it to the task.""" + git_service = get_git_service(db) + try: + ( + commit_hash, + message, + files_changed, + insertions, + deletions, + ) = await git_service.commit_for_task(agent.agent_id, data) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitCommitResponse( + commit_hash=commit_hash, + message=message, + files_changed=files_changed, + insertions=insertions, + deletions=deletions, + ) + + +@router.post("/push", response_model=GitPushResponse) +async def push_commits( + data: GitPushRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitPushResponse: + """Push commits to remote.""" + git_service = get_git_service(db) + try: + branch, commits_pushed = await git_service.push_for_task( + agent.agent_id, agent.role, data + ) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitPushResponse( + branch=branch, + commits_pushed=commits_pushed, + remote="origin", + ready_for_pr=commits_pushed > 0, + ) + + +@router.post("/branch/create", response_model=GitCreateBranchResponse) +async def create_branch( + data: GitCreateBranchRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitCreateBranchResponse: + """Create a task branch (PM only). + + Uses hierarchical branch naming: {type}/{team}/{root}/{sub}/{subsub} + """ + git_service = get_git_service(db) + try: + branch_name, created_from = await git_service.create_branch_for_task( + agent.agent_id, data + ) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitCreateBranchResponse( + branch_name=branch_name, + created_from=created_from, + project_slug=data.project_slug, + ) + + +@router.post("/checkout", response_model=GitCheckoutResponse) +async def checkout_branch( + data: GitCheckoutRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitCheckoutResponse: + """Checkout a branch. + + Restricted to branches the agent has a legitimate reason to be on: + any of their own assigned tasks' branches, or the project's default + base branch for read-only inspection. Prevents agents from jumping + to `master` / sibling branches and committing there by accident. + """ + git_service = get_git_service(db) + try: + await git_service.checkout_branch_for_agent(agent.agent_id, data) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitCheckoutResponse( + branch=data.branch, + project_slug=data.project_slug, + ) + + +@router.post("/pr/create", response_model=GitCreatePRResponse) +async def create_pull_request( + data: GitCreatePRRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitCreatePRResponse: + """Create a pull request and sync task/work-session state atomically.""" + git_service = get_git_service(db) + try: + ( + pr_number, + pr_url, + title, + source_branch, + target_branch, + ) = await git_service.create_pr_for_task(agent.agent_id, data) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitCreatePRResponse( + pr_number=pr_number, + pr_url=pr_url, + title=title, + source_branch=source_branch, + target_branch=target_branch, + ) + + +@router.post("/pr/merge", response_model=GitMergePRResponse) +async def merge_pull_request( + data: GitMergePRRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitMergePRResponse: + """Merge a PR (PM/CEO). Auto-completes the task on role match.""" + git_service = get_git_service(db) + try: + target_branch, merge_commit = await git_service.merge_pr_for_task( + agent.agent_id, agent.role, data + ) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitMergePRResponse( + pr_number=data.pr_number, + merged=True, + merge_commit=merge_commit, + target_branch=target_branch, + ) + + +@router.post("/pull", response_model=GitPullResponse) +async def pull_commits( + data: GitPullRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitPullResponse: + """Pull latest changes from origin into the agent workspace.""" + project_slug = await _resolve_project_slug(data.project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + ( + current_branch, + has_changes, + staged, + unstaged, + untracked, + ahead, + behind, + ) = await git_service.pull(workspace) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitPullResponse( + project_slug=project_slug, + current_branch=current_branch, + has_changes=has_changes, + staged_files=staged, + unstaged_files=unstaged, + untracked_files=untracked, + ahead=ahead, + behind=behind, + ) + + +@router.post("/fetch", response_model=GitFetchResponse) +async def fetch_commits( + data: GitFetchRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitFetchResponse: + """Fetch changes from origin without merging.""" + project_slug = await _resolve_project_slug(data.project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + ( + current_branch, + has_changes, + staged, + unstaged, + untracked, + ahead, + behind, + ) = await git_service.fetch(workspace) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitFetchResponse( + project_slug=project_slug, + current_branch=current_branch, + has_changes=has_changes, + staged_files=staged, + unstaged_files=unstaged, + untracked_files=untracked, + ahead=ahead, + behind=behind, + ) + + +@router.post("/rebase", response_model=GitRebaseResponse) +async def rebase_branch( + data: GitRebaseRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GitRebaseResponse: + """Rebase the current branch onto target_branch. + + Role-gated: only CEO and PM roles (cell_pm, main_pm) may rebase branches. + Developers, QA, documenters, and other roles are rejected with 403. + + If task_id is provided and the caller is not CEO, the task's assigned_to + is checked: if the task is not assigned to the calling agent, 403 is + returned (or 404 if the task does not exist). + + On conflict: aborts the rebase and returns conflict=True with the + list of conflicted files. On success: returns conflict=False. + """ + if agent.role not in _REBASE_ALLOWED_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"REBASE_ROLE_RESTRICTED: Role '{agent.role}' is not permitted " + "to rebase. Only CEO and PM roles (cell_pm, main_pm) may use " + "this endpoint." + ), + ) + # Task ownership check: if a task_id is supplied and the caller is not CEO, + # ensure the task is assigned to the calling agent. + if data.task_id is not None and agent.role != AgentRole.CEO: + task_service = get_task_service(db) + task = await task_service.get(data.task_id) + if task is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Task not found: {data.task_id}", + ) + if task.assigned_to != agent.agent_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "REBASE_OWNERSHIP_RESTRICTED: This task is not assigned to " + "you. Only the task's assigned agent or CEO may rebase it." + ), + ) + project_slug = await _resolve_project_slug(data.project_slug, db) + git_service = get_git_service(db) + + try: + workspace = await git_service.get_workspace(project_slug, agent.agent_id) + conflict, conflicted_files = await git_service.rebase( + workspace, data.target_branch + ) + except _TranslatableError as e: + raise _translate_error(e) from e + + return GitRebaseResponse( + project_slug=project_slug, + conflict=conflict, + conflicted_files=conflicted_files, + ) diff --git a/roboco/api/routes/v1/_role_dep.py b/roboco/api/routes/v1/_role_dep.py new file mode 100644 index 0000000..39ebeb3 --- /dev/null +++ b/roboco/api/routes/v1/_role_dep.py @@ -0,0 +1,62 @@ +"""Role-asserting dependencies and shared helpers for v1 flow routers. + +Every router gets one of these as a dependency so the role check happens +before the choreographer body even runs. Defense in depth — the +choreographer also re-checks role internally for verbs that branch on it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any, cast + +from fastapi import Depends, Header, HTTPException, params, status + +from roboco.foundation.identity import Role + +if TYPE_CHECKING: + from fastapi import Request + + from roboco.services.gateway.envelope import Envelope + + +def _require_roles(allowed: frozenset[Role]) -> params.Depends: + def _check( + x_agent_role: Annotated[str, Header(alias="X-Agent-Role")], + ) -> None: + # `Role` is a StrEnum, so the lowercase header string compares equal + # to its matching member. + if x_agent_role.lower() not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"role '{x_agent_role}' not allowed for this endpoint group", + ) + + return cast("params.Depends", Depends(_check)) + + +# Role-typed single-role guards — renaming a role edits foundation.identity only. +# `require_board` is the only multi-role guard (Product Owner + Head of Marketing +# share the public-facing board endpoints; the auditor has its own guard). +require_dev = _require_roles(frozenset({Role.DEVELOPER})) +require_qa = _require_roles(frozenset({Role.QA})) +require_doc = _require_roles(frozenset({Role.DOCUMENTER})) +require_cell_pm = _require_roles(frozenset({Role.CELL_PM})) +require_main_pm = _require_roles(frozenset({Role.MAIN_PM})) +require_board = _require_roles(frozenset({Role.PRODUCT_OWNER, Role.HEAD_MARKETING})) +require_auditor = _require_roles(frozenset({Role.AUDITOR})) +require_pr_reviewer = _require_roles(frozenset({Role.PR_REVIEWER})) + + +def envelope_to_response(env: Envelope, request: Request) -> dict[str, Any]: + """Stamp the request's correlation_id onto the envelope and return wire-dict. + + ``CorrelationIdMiddleware`` writes the inbound (or freshly-generated) + ``X-Correlation-ID`` to ``request.state.correlation_id``. We pull it + here so the agent receives the same id it sent (or can capture the + server-generated one) and ops can join logs across the full + MCP -> API -> service hop. + """ + cid = getattr(request.state, "correlation_id", None) + if cid is not None and env.correlation_id is None: + env.correlation_id = cid + return env.as_dict() diff --git a/roboco/services/gateway/kb_authz.py b/roboco/services/gateway/kb_authz.py new file mode 100644 index 0000000..bf486b9 --- /dev/null +++ b/roboco/services/gateway/kb_authz.py @@ -0,0 +1,90 @@ +"""Authorization decisions for docs/optimal, expressed as gateway Envelopes. + +The HTTP routes for documentation (`api.routes.docs`) and the knowledge +base (`api.routes.optimal`) used to embed RBAC checks inline and raise raw +``HTTPException(403)`` with no recovery hint. That mixed an authorization +decision into the HTTP layer and broke the gateway Envelope contract: +agents received ``remediate=null`` and had nothing actionable to do. + +This module owns those decisions. It turns a denial into an +``Envelope.not_authorized(...)`` carrying a non-null ``remediate`` that +names the roles allowed to perform the action, so the agent knows how to +recover (escalate to a role that holds the permission). The routes stay +thin: they ask here for a verdict and translate it to the wire. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from roboco.models.permissions import KB_PERMISSIONS +from roboco.services.gateway.envelope import Envelope + +if TYPE_CHECKING: + from roboco.models.permissions import AgentContext + from roboco.services.permissions import PermissionService + + +def _roles_allowed_for(action: str) -> list[str]: + """Roles whose KB permission set includes ``action`` (sorted, stable).""" + return sorted( + role.value for role, actions in KB_PERMISSIONS.items() if action in actions + ) + + +def _remediate_for_action(action: str) -> str: + """Recovery hint naming who can perform ``action``.""" + allowed = _roles_allowed_for(action) + if allowed: + return ( + f"role not permitted for '{action}' — ask one of these roles to " + f"run it: {', '.join(allowed)}" + ) + return f"'{action}' is not granted to any role; escalate to the CEO" + + +def authorize_kb_action( + permissions: PermissionService, + agent: AgentContext, + action: str, +) -> Envelope | None: + """Verdict on a knowledge-base action. + + Returns ``None`` when allowed. On denial returns an + ``Envelope.not_authorized`` whose ``remediate`` tells the agent which + roles may perform the action. + """ + if permissions.can_perform_kb_action(agent, action): + return None + return Envelope.not_authorized( + message=f"role '{agent.role.value}' not authorized to {action}", + remediate=_remediate_for_action(action), + ) + + +_DOCS_WRITE_ACTIONS = frozenset({"write_doc", "delete_doc"}) + + +def docs_denial_envelope(action: str, reason: str | None) -> Envelope: + """Wrap a docs-service authorization denial as a gateway Envelope. + + The docs RBAC decision already lives in ``DocsService`` (it raises + ``UnauthorizedError`` with an ``action`` and human ``reason``). This + keeps the remediate-hint ownership in the gateway: the route hands the + denial here and gets back the Envelope-shaped body with a non-null + ``remediate``. + """ + if action in _DOCS_WRITE_ACTIONS: + remediate = ( + f"role not permitted to {action} — only documenters and cell PMs " + "may write or delete docs; ask a documenter to perform it" + ) + else: + remediate = ( + f"role not permitted to {action} — ask a documenter or cell PM, " + "or use roboco_kb_search to find the document instead" + ) + return Envelope.not_authorized( + message=reason or f"not authorized: {action}", + remediate=remediate, + )