mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(orchestrator): add respawn_tracker table + migration 051 Durable backing for AgentOrchestrator._pm_respawn_tracker (the PM-respawn loop breaker). Kept only in memory it reset to count=1 on every restart, re-burning the strike threshold against a still-wedged task. RespawnTrackerTable mirrors WaitingRecordTable: composite PK (agent_slug, task_id) matching the in-memory key; task_id is intentionally NOT a FK (the startup loader validates against live tasks so a stale counter can't resurrect). Migration 051 verified with a real alembic upgrade head + downgrade -1 + re-upgrade on Postgres. * feat(orchestrator): persist the PM-respawn counter across restarts The PM-respawn loop breaker (_pm_respawn_tracker) lived only in memory, so an orchestrator restart reset a wedged task's strike count to 1 and re-burned the whole threshold (4 spawns x container cost) before the gate fired again. Write-through each gate mutation to the respawn_tracker table via a fire-and-forget _schedule_respawn_persist (on the existing _bg_tasks strong-ref set; a DB hiccup degrades to in-memory-only, never gates/un-gates a spawn), and restore_respawn_tracker() repopulates the counter at startup, validating each row against live tasks (drops terminal/missing) so a stale counter can't resurrect against a fixed task. Best-effort + inert when the table is empty. Cannot manufacture a spawn — the counter only ever suppresses one. (_instances reconcile, the spec's other goal, already shipped as _readopt_running_agents.) * fix(types): cast Mapped[UUID] columns in project routes + self_heal A clean `mypy roboco/ tests/` run surfaces 7 pre-existing errors in files this branch doesn't touch: project-route handlers and self_heal_engine pass a ProjectTable.id (declared Mapped[UUID] against SQLAlchemy's dialect UUID, so mypy infers sqlalchemy.sql.sqltypes.UUID[Any]) where a uuid.UUID is expected. An incremental .mypy_cache had hidden them. Apply the same targeted cast unblock used for the prior batch; the deeper fix (migrating the ~88 Mapped[UUID] columns to Mapped[uuid.UUID]) remains a separate dedicated task. * docs(orchestrator): document respawn_tracker durability Add the orchestrator runtime-state durability note to CLAUDE.md (respawn_tracker write-through + restore; _instances reconciled-from-Docker) + the migration-051 narrative, and a CHANGELOG [Unreleased] Fixed entry. Also type-clean the new respawn_tracker table test (cast __table__ to Table under TYPE_CHECKING). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
545 lines
16 KiB
Python
545 lines
16 KiB
Python
"""
|
|
Project API Routes
|
|
|
|
CRUD operations for managing git projects/repositories.
|
|
"""
|
|
|
|
from typing import TYPE_CHECKING, Annotated, cast
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
|
|
|
if TYPE_CHECKING:
|
|
from roboco.db.tables import ProjectTable
|
|
|
|
from roboco.api.deps import (
|
|
CurrentAgentContext,
|
|
DbSession,
|
|
require_cell_access,
|
|
require_pm_or_above,
|
|
)
|
|
from roboco.api.schemas.project import (
|
|
ConventionFinding,
|
|
ConventionsActionResponse,
|
|
ConventionsHealthResponse,
|
|
ConventionsResponse,
|
|
ProjectCreateRequest,
|
|
ProjectResponse,
|
|
ProjectSummaryResponse,
|
|
ProjectUpdateRequest,
|
|
SetWorkspaceRequest,
|
|
SyncStateRequest,
|
|
project_to_response,
|
|
project_to_summary,
|
|
)
|
|
from roboco.foundation.policy.conventions.models import ConventionsStandard
|
|
from roboco.models.base import Team
|
|
from roboco.models.project import ProjectCreate, ProjectUpdate
|
|
from roboco.services.conventions import (
|
|
ScaffoldResult,
|
|
get_conventions_service,
|
|
)
|
|
from roboco.services.project import ProjectService, get_project_service
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# =============================================================================
|
|
# LIST & GET ENDPOINTS
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("", response_model=list[ProjectSummaryResponse])
|
|
async def list_projects(
|
|
db: DbSession,
|
|
_agent: CurrentAgentContext,
|
|
cell: Annotated[Team | None, Query(description="Filter by cell")] = None,
|
|
active_only: Annotated[bool, Query(description="Only active projects")] = True,
|
|
limit: Annotated[int, Query(le=500)] = 100,
|
|
offset: Annotated[int, Query(ge=0)] = 0,
|
|
) -> list[ProjectSummaryResponse]:
|
|
"""
|
|
List projects with optional filters.
|
|
|
|
All agents can list projects, but Cell PMs only see their cell's projects
|
|
unless they have global access.
|
|
"""
|
|
service = get_project_service(db)
|
|
|
|
if cell:
|
|
projects = await service.list_by_cell(cell, active_only=active_only)
|
|
else:
|
|
projects = await service.list_all(
|
|
active_only=active_only,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
return [project_to_summary(p) for p in projects]
|
|
|
|
|
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
async def get_project(
|
|
project_id: str,
|
|
db: DbSession,
|
|
_agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Get project by ID or slug.
|
|
|
|
Accepts either a UUID string or project slug (e.g., "roboco").
|
|
"""
|
|
service = get_project_service(db)
|
|
|
|
# Try UUID first, then slug
|
|
try:
|
|
uuid = UUID(project_id)
|
|
project = await service.get(uuid)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
return project_to_response(project)
|
|
|
|
|
|
# =============================================================================
|
|
# CREATE ENDPOINT
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_project(
|
|
data: ProjectCreateRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Register a new project (PM only).
|
|
|
|
Creates a project record for a git repository.
|
|
The workspace must be cloned separately.
|
|
"""
|
|
require_pm_or_above(agent.role, "create projects")
|
|
require_cell_access(agent, data.assigned_cell, "create")
|
|
|
|
service = get_project_service(db)
|
|
|
|
# If protected_branches wasn't provided, default to just the default_branch
|
|
protected_branches = data.protected_branches
|
|
if protected_branches is None:
|
|
protected_branches = [data.default_branch]
|
|
|
|
# Convert request to service model
|
|
create_data = ProjectCreate(
|
|
name=data.name,
|
|
slug=data.slug,
|
|
git_url=data.git_url,
|
|
default_branch=data.default_branch,
|
|
protected_branches=protected_branches,
|
|
assigned_cell=data.assigned_cell,
|
|
git_token=data.git_token,
|
|
test_command=data.test_command,
|
|
lint_command=data.lint_command,
|
|
format_command=data.format_command,
|
|
typecheck_command=data.typecheck_command,
|
|
build_command=data.build_command,
|
|
)
|
|
|
|
try:
|
|
project = await service.create(create_data, created_by=agent.agent_id)
|
|
await db.commit()
|
|
return project_to_response(project)
|
|
except Exception as e:
|
|
await db.rollback()
|
|
if "already exists" in str(e):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=str(e),
|
|
) from e
|
|
raise
|
|
|
|
|
|
# =============================================================================
|
|
# UPDATE ENDPOINT
|
|
# =============================================================================
|
|
|
|
|
|
@router.patch("/{project_id}", response_model=ProjectResponse)
|
|
async def update_project(
|
|
project_id: str,
|
|
data: ProjectUpdateRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Update a project (PM only).
|
|
|
|
Partial update - only provided fields are changed.
|
|
"""
|
|
require_pm_or_above(agent.role, "update projects")
|
|
|
|
service = get_project_service(db)
|
|
|
|
# Get project first to check cell access
|
|
try:
|
|
uuid = UUID(project_id)
|
|
project = await service.get(uuid)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
require_cell_access(agent, project.assigned_cell, "update")
|
|
|
|
# Convert request to service model
|
|
update_data = ProjectUpdate(
|
|
name=data.name,
|
|
git_url=data.git_url,
|
|
default_branch=data.default_branch,
|
|
protected_branches=data.protected_branches,
|
|
assigned_cell=data.assigned_cell,
|
|
git_token=data.git_token,
|
|
test_command=data.test_command,
|
|
lint_command=data.lint_command,
|
|
format_command=data.format_command,
|
|
typecheck_command=data.typecheck_command,
|
|
build_command=data.build_command,
|
|
quality_command=data.quality_command,
|
|
ci_watch_enabled=data.ci_watch_enabled,
|
|
ci_watch_workflow=data.ci_watch_workflow,
|
|
dep_update_command=data.dep_update_command,
|
|
dep_update_paths=data.dep_update_paths,
|
|
is_active=data.is_active,
|
|
)
|
|
|
|
updated = await service.update(cast("UUID", project.id), update_data)
|
|
await db.commit()
|
|
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update project",
|
|
)
|
|
|
|
return project_to_response(updated)
|
|
|
|
|
|
# =============================================================================
|
|
# DELETE ENDPOINT
|
|
# =============================================================================
|
|
|
|
|
|
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_project(
|
|
project_id: str,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> None:
|
|
"""
|
|
Delete a project (PM only).
|
|
|
|
This removes the project registration. The actual git repository
|
|
and workspace are not affected.
|
|
"""
|
|
require_pm_or_above(agent.role, "delete projects")
|
|
|
|
service = get_project_service(db)
|
|
|
|
# Get project first to check cell access
|
|
try:
|
|
uuid = UUID(project_id)
|
|
project = await service.get(uuid)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
require_cell_access(agent, project.assigned_cell, "delete")
|
|
|
|
deleted = await service.delete(cast("UUID", project.id))
|
|
await db.commit()
|
|
|
|
if not deleted:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to delete project",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# WORKSPACE MANAGEMENT
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{project_id}/workspace", response_model=ProjectResponse)
|
|
async def set_workspace(
|
|
project_id: str,
|
|
data: SetWorkspaceRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Set the local workspace path for a project (PM only).
|
|
|
|
Called after cloning the repository to a local path.
|
|
"""
|
|
require_pm_or_above(agent.role, "set workspace")
|
|
|
|
service = get_project_service(db)
|
|
|
|
try:
|
|
uuid = UUID(project_id)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
) from None
|
|
uuid = cast("UUID", project.id)
|
|
|
|
updated = await service.set_workspace_path(uuid, data.workspace_path)
|
|
await db.commit()
|
|
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
return project_to_response(updated)
|
|
|
|
|
|
@router.post("/{project_id}/sync", response_model=ProjectResponse)
|
|
async def update_sync_state(
|
|
project_id: str,
|
|
data: SyncStateRequest,
|
|
db: DbSession,
|
|
_agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Update the sync state after a git pull/fetch.
|
|
|
|
Records the current HEAD commit and sync timestamp.
|
|
"""
|
|
service = get_project_service(db)
|
|
|
|
try:
|
|
uuid = UUID(project_id)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
) from None
|
|
uuid = cast("UUID", project.id)
|
|
|
|
updated = await service.update_sync_state(uuid, data.head_commit)
|
|
await db.commit()
|
|
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
return project_to_response(updated)
|
|
|
|
|
|
# =============================================================================
|
|
# ACCESS CONTROL
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{project_id}/access/{agent_id}", response_model=ProjectResponse)
|
|
async def add_agent_access(
|
|
project_id: str,
|
|
agent_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Add an agent to the project's allowed list (PM only).
|
|
|
|
By default, all agents in the assigned cell have access.
|
|
This restricts access to specific agents.
|
|
"""
|
|
require_pm_or_above(agent.role, "manage access")
|
|
|
|
service = get_project_service(db)
|
|
|
|
try:
|
|
uuid = UUID(project_id)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
) from None
|
|
uuid = cast("UUID", project.id)
|
|
|
|
updated = await service.add_allowed_agent(uuid, agent_id)
|
|
await db.commit()
|
|
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
return project_to_response(updated)
|
|
|
|
|
|
@router.delete("/{project_id}/access/{agent_id}", response_model=ProjectResponse)
|
|
async def remove_agent_access(
|
|
project_id: str,
|
|
agent_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ProjectResponse:
|
|
"""
|
|
Remove an agent from the project's allowed list (PM only).
|
|
"""
|
|
require_pm_or_above(agent.role, "manage access")
|
|
|
|
service = get_project_service(db)
|
|
|
|
try:
|
|
uuid = UUID(project_id)
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
) from None
|
|
uuid = cast("UUID", project.id)
|
|
|
|
updated = await service.remove_allowed_agent(uuid, agent_id)
|
|
await db.commit()
|
|
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
|
|
return project_to_response(updated)
|
|
|
|
|
|
# =============================================================================
|
|
# CONVENTIONS ENDPOINTS
|
|
# =============================================================================
|
|
|
|
|
|
async def _get_project_or_404(
|
|
service: ProjectService, project_id: str
|
|
) -> "ProjectTable":
|
|
"""Resolve a project by UUID or slug, raising 404 when absent."""
|
|
try:
|
|
project = await service.get(UUID(project_id))
|
|
except ValueError:
|
|
project = await service.get_by_slug(project_id)
|
|
if project is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Project not found: {project_id}",
|
|
)
|
|
return project
|
|
|
|
|
|
def _action_response(result: ScaffoldResult) -> ConventionsActionResponse:
|
|
return ConventionsActionResponse(
|
|
pr_number=result.pr_number, branch=result.branch, created=result.created
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/conventions", response_model=ConventionsResponse)
|
|
async def get_conventions(
|
|
project_id: str,
|
|
db: DbSession,
|
|
_agent: CurrentAgentContext,
|
|
) -> ConventionsResponse:
|
|
"""Return the project's effective conventions map + its current health."""
|
|
project = await _get_project_or_404(get_project_service(db), project_id)
|
|
conv = get_conventions_service(db)
|
|
# Ensure a default-branch read clone once, then read the map + health from
|
|
# it. This is the backfill: a project created before the standard existed
|
|
# (no manual workspace_path) still resolves its committed conventions file.
|
|
workspace = await conv.resolve_workspace(project)
|
|
standard = await conv.get_map(project, workspace=workspace)
|
|
health = await conv.health(project, workspace=workspace)
|
|
await db.commit()
|
|
return ConventionsResponse(
|
|
standard=standard.model_dump(mode="json"),
|
|
health=ConventionsHealthResponse(
|
|
status=health.status,
|
|
head_sha=health.head_sha,
|
|
last_ok_sha=health.last_ok_sha,
|
|
),
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/conventions", response_model=ConventionsActionResponse)
|
|
async def update_conventions(
|
|
project_id: str,
|
|
standard: ConventionsStandard,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ConventionsActionResponse:
|
|
"""Commit an edited conventions standard back to the repo via a PR (PM+)."""
|
|
require_pm_or_above(agent.role, "edit conventions")
|
|
project = await _get_project_or_404(get_project_service(db), project_id)
|
|
result = await get_conventions_service(db).commit_standard(project, standard)
|
|
await db.commit()
|
|
return _action_response(result)
|
|
|
|
|
|
@router.post(
|
|
"/{project_id}/conventions/restore", response_model=ConventionsActionResponse
|
|
)
|
|
async def restore_conventions(
|
|
project_id: str,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> ConventionsActionResponse:
|
|
"""Re-commit the conventions file from the last-good map via a PR (PM+)."""
|
|
require_pm_or_above(agent.role, "restore conventions")
|
|
project = await _get_project_or_404(get_project_service(db), project_id)
|
|
result = await get_conventions_service(db).restore(project)
|
|
await db.commit()
|
|
return _action_response(result)
|
|
|
|
|
|
@router.get(
|
|
"/{project_id}/conventions/findings",
|
|
response_model=list[ConventionFinding],
|
|
)
|
|
async def get_conventions_findings(
|
|
project_id: str,
|
|
db: DbSession,
|
|
_agent: CurrentAgentContext,
|
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
) -> list[ConventionFinding]:
|
|
"""Recent architectural-conventions findings for the project (violations feed)."""
|
|
project = await _get_project_or_404(get_project_service(db), project_id)
|
|
rows = await get_conventions_service(db).recent_findings(
|
|
UUID(str(project.id)), limit
|
|
)
|
|
return [ConventionFinding(**row) for row in rows]
|