Files
roboco/roboco/services/project.py
T
16789c1ca7 Feature/architectural conventions standard (#243)
* feat(conventions): standard schema models + effective-map merge

* feat(conventions): tree-sitter Python classifier + placement checks

* feat(conventions): TS classifier, hygiene/custom checks, runner + CLI

* feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration

* feat(conventions): repo auto-scan + scaffold draft renderer

* feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore)

* feat(conventions): auto-scaffold on project registration (flag-gated)

* feat(conventions): TaskDescription.constraints + auto-baseline attach

* feat(conventions): ambient architecture-map injection at spawn

* test(conventions): subprocess CLI smoke for the agent-image entrypoint

* feat(conventions): block i_am_done on block-level convention violations

* feat(conventions): block pr_pass on unresolved convention violations

* feat(conventions): surface convention findings into QA evidence

* docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer

* feat(conventions): panel Conventions tab + flag toggle + parity

* test(conventions): end-to-end block, fix, and waiver through the gate

* refactor(conventions): extract pr_pass guards to keep pr_gate under the gate

* style(conventions): format the baseline-constraints attach in task.create

* test(conventions): type-annotate test helpers for the full mypy gate

* build(conventions): ignore types-PyYAML in deptry (mypy-only type stub)

* docs(conventions): document the standard in CLAUDE.md + PM prompt awareness

* fix(conventions): baseline constraints are non-suppressible (dedup-append)

* feat(conventions): scaffold on first workspace clone (threaded workspace)

* feat(conventions): multi-project ambient map for PO/Intake (per-product)

* feat(conventions): persist findings + violations-feed route (migration 044)

* feat(conventions): panel violations feed in the Conventions tab

* test(conventions): intake-spawn mock accepts the ambient layer kwarg

* fix(docker): ollama-init best-effort pull, gate startup on cached models present

A degraded/slow ollama registry made the model manifest re-check fail under
set -e, so ollama-init exited 1 and blocked the orchestrator's
service_completed_successfully gate — taking the whole stack down even though
both models were already cached. Pulls are now best-effort; success is gated on
the models being present, so a flaky registry can't down a cached deployment.

* refactor(content): drop dead TaskDescription.with_baseline_constraints

The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-22 12:37:46 +02:00

605 lines
19 KiB
Python

"""
Project Service
Provides CRUD operations and git workflow management for projects.
Projects represent git repositories that agents work on.
"""
from typing import ClassVar
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.config import settings
from roboco.db.tables import ProjectTable
from roboco.exceptions import ValidationError
from roboco.models.base import Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
class ProjectService(BaseService):
"""
Service for managing projects (git repositories).
Provides:
- CRUD operations
- Slug-based lookups
- Project listing by cell/team
- Workspace path management
"""
service_name: ClassVar[str] = "project"
# =========================================================================
# CRUD OPERATIONS
# =========================================================================
def _assert_git_url_allowed(self, git_url: str | None) -> None:
"""Reject a project repo URL that matches a protected (denylisted) repo.
Keeps agent commits/merges out of a repository that must not receive
them — e.g. the roboco source repo during a smoke run.
"""
if not git_url:
return
for protected in settings.protected_git_urls:
if protected and protected in git_url:
raise ValidationError(
"Project git_url may not point at a protected repository "
f"('{protected}').",
field="git_url",
)
async def create(
self,
data: ProjectCreate,
created_by: UUID,
) -> ProjectTable:
"""
Create/register a new project.
Args:
data: Project creation data
created_by: PM who is registering the project
Returns:
The created project
Raises:
ConflictError: If project with same slug already exists
"""
# Check for duplicate slug
existing = await self.get_by_slug(data.slug)
if existing:
raise ConflictError(
f"Project with slug '{data.slug}' already exists",
resource_type="project",
)
self._assert_git_url_allowed(data.git_url)
# Encrypt git token if provided
encrypted_token = None
if data.git_token:
try:
encrypted_token = encrypt_token(data.git_token)
except EncryptionError as e:
self.log.error("Failed to encrypt git token", error=str(e))
raise
project = ProjectTable(
name=data.name,
slug=data.slug,
git_url=data.git_url,
default_branch=data.default_branch,
protected_branches=data.protected_branches,
assigned_cell=data.assigned_cell,
git_token_encrypted=encrypted_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,
created_by=created_by,
)
self.session.add(project)
await self.session.flush()
self.log.info(
"Project created",
project_id=str(project.id),
slug=data.slug,
git_url=data.git_url,
has_git_token=bool(encrypted_token),
cell=data.assigned_cell.value
if isinstance(data.assigned_cell, Team)
else data.assigned_cell,
)
await self._maybe_scaffold_conventions(project)
return project
async def _maybe_scaffold_conventions(self, project: ProjectTable) -> None:
"""Best-effort: open the conventions scaffold PR (flag-gated, never fatal).
Imported lazily to avoid the project -> conventions -> git -> project
import cycle. A scaffold hiccup must never fail project registration.
"""
if not settings.conventions_enabled:
return
from roboco.services.conventions import get_conventions_service
try:
await get_conventions_service(self.session).scaffold(project)
except Exception as exc:
self.log.warning(
"Conventions scaffold failed (non-fatal)",
project_id=str(project.id),
error=str(exc),
)
async def get(self, project_id: UUID) -> ProjectTable | None:
"""Get a project by ID."""
result = await self.session.execute(
select(ProjectTable).where(ProjectTable.id == project_id)
)
return result.scalar_one_or_none()
async def get_by_slug(self, slug: str) -> ProjectTable | None:
"""Get a project by its URL-safe slug."""
result = await self.session.execute(
select(ProjectTable).where(ProjectTable.slug == slug)
)
return result.scalar_one_or_none()
async def get_or_raise(self, project_id: UUID) -> ProjectTable:
"""Get a project by ID or raise NotFoundError."""
project = await self.get(project_id)
if not project:
raise NotFoundError("Project", str(project_id))
return project
async def update(
self,
project_id: UUID,
data: ProjectUpdate,
) -> ProjectTable | None:
"""
Update a project.
Args:
project_id: Project to update
data: Update data (only non-None fields are applied)
Returns:
The updated project or None if not found
"""
project = await self.get(project_id)
if not project:
return None
self._assert_git_url_allowed(data.git_url)
# Handle git_token specially (empty string clears, None leaves unchanged)
token_updated = False
if data.git_token is not None:
if data.git_token == "":
# Clear the token
project.git_token_encrypted = None
token_updated = True
self.log.info("Git token cleared", project_id=str(project_id))
else:
# Encrypt and set new token
try:
project.git_token_encrypted = encrypt_token(data.git_token)
token_updated = True
self.log.info("Git token updated", project_id=str(project_id))
except EncryptionError as e:
self.log.error("Failed to encrypt git token", error=str(e))
raise
# Apply updates for non-None fields (excluding git_token which we handled)
update_data = data.model_dump(
exclude_unset=True, exclude_none=True, exclude={"git_token"}
)
for key, value in update_data.items():
if hasattr(project, key):
setattr(project, key, value)
await self.session.flush()
updated_fields = list(update_data.keys())
if token_updated:
updated_fields.append("git_token")
self.log.info(
"Project updated",
project_id=str(project_id),
updates=updated_fields,
)
return project
async def delete(
self,
project_id: UUID,
*,
delete_workspaces: bool = False,
) -> bool:
"""
Delete a project.
Before the delete, abandon any ACTIVE work sessions tied to this
project so they don't remain ACTIVE with no owning project.
Optionally remove cloned workspaces from disk (caller opt-in; safer
default is to leave them for recovery).
The DB-level cascade on tasks is `RESTRICT`, so if any tasks
reference this project the delete still fails at the DB layer —
callers should cancel those tasks first.
Args:
project_id: Project to delete
delete_workspaces: If True, remove on-disk workspaces for this
project. Default False so disk cleanup is explicit.
Returns:
True if deleted, False if not found
"""
project = await self.get(project_id)
if not project:
return False
from roboco.db.tables import WorkSessionTable
from roboco.models.work_session import WorkSessionStatus
from roboco.services.work_session import get_work_session_service
active_sessions = await self.session.execute(
select(WorkSessionTable).where(
WorkSessionTable.project_id == project_id,
WorkSessionTable.status == WorkSessionStatus.ACTIVE,
)
)
ws_service = get_work_session_service(self.session)
abandoned = 0
for ws in active_sessions.scalars().all():
from roboco.utils.converters import require_uuid
await ws_service.abandon(require_uuid(ws.id), reason="project deleted")
abandoned += 1
if abandoned:
self.log.info(
"Abandoned active work sessions before project delete",
project_id=str(project_id),
count=abandoned,
)
project_slug = project.slug
await self.session.delete(project)
await self.session.flush()
if delete_workspaces:
from roboco.services.workspace import get_workspace_service
ws_svc = get_workspace_service(self.session)
try:
workspaces = await ws_svc.list_workspaces(project_slug)
for ws_info in workspaces:
from pathlib import Path
path = Path(ws_info["path"])
if path.exists():
import shutil
shutil.rmtree(path)
self.log.info(
"Deleted workspaces for project",
project_slug=project_slug,
count=len(workspaces),
)
except Exception as e:
self.log.warning(
"Failed to clean up some workspaces after project delete",
project_slug=project_slug,
error=str(e),
)
self.log.info("Project deleted", project_id=str(project_id))
return True
# =========================================================================
# QUERIES
# =========================================================================
async def list_all(
self,
active_only: bool = True,
limit: int = 100,
offset: int = 0,
) -> list[ProjectTable]:
"""
List all projects with pagination.
Args:
active_only: If True, only return active projects
limit: Maximum number of projects to return
offset: Number of projects to skip
Returns:
List of projects
"""
query = select(ProjectTable)
if active_only:
query = query.where(ProjectTable.is_active.is_(True))
query = query.order_by(ProjectTable.name).limit(limit).offset(offset)
result = await self.session.execute(query)
return list(result.scalars().all())
async def list_by_cell(
self,
cell: Team,
active_only: bool = True,
) -> list[ProjectTable]:
"""
List projects assigned to a specific cell.
Args:
cell: The team/cell to filter by
active_only: If True, only return active projects
Returns:
List of projects for the cell
"""
query = select(ProjectTable).where(ProjectTable.assigned_cell == cell)
if active_only:
query = query.where(ProjectTable.is_active.is_(True))
query = query.order_by(ProjectTable.name)
result = await self.session.execute(query)
return list(result.scalars().all())
# =========================================================================
# WORKSPACE MANAGEMENT
# =========================================================================
async def set_workspace_path(
self,
project_id: UUID,
workspace_path: str,
) -> ProjectTable | None:
"""
Set the local workspace path for a project.
Called after cloning/syncing the repository.
Args:
project_id: Project to update
workspace_path: Path to the local workspace directory
Returns:
The updated project or None if not found
"""
project = await self.get(project_id)
if not project:
return None
project.workspace_path = workspace_path
await self.session.flush()
self.log.info(
"Workspace path set",
project_id=str(project_id),
workspace_path=workspace_path,
)
return project
async def update_sync_state(
self,
project_id: UUID,
head_commit: str,
) -> ProjectTable | None:
"""
Update the sync state after a git pull/fetch.
Args:
project_id: Project to update
head_commit: Current HEAD commit SHA
Returns:
The updated project or None if not found
"""
from datetime import UTC, datetime
project = await self.get(project_id)
if not project:
return None
project.head_commit = head_commit
project.last_synced_at = datetime.now(UTC)
await self.session.flush()
self.log.info(
"Sync state updated",
project_id=str(project_id),
head_commit=head_commit[:8], # Short SHA
)
return project
# =========================================================================
# GIT TOKEN MANAGEMENT
# =========================================================================
async def get_decrypted_token(self, project_id: UUID) -> str | None:
"""
Get the decrypted git token for a project.
Used by WorkspaceService and GitService for git operations.
The token is decrypted on-demand and should not be cached.
Args:
project_id: Project to get token for
Returns:
Decrypted token or None if no token is set
Raises:
EncryptionError: If decryption fails (key mismatch, corrupted data)
"""
project = await self.get(project_id)
if not project or not project.git_token_encrypted:
return None
try:
return decrypt_token(project.git_token_encrypted)
except EncryptionError:
self.log.error(
"Failed to decrypt git token",
project_id=str(project_id),
error="encryption_key_mismatch_or_corrupted",
)
raise
async def get_decrypted_token_by_slug(self, slug: str) -> str | None:
"""
Get the decrypted git token for a project by slug.
Convenience method for services that work with project slugs.
Args:
slug: Project slug
Returns:
Decrypted token or None if no token is set
Raises:
EncryptionError: If decryption fails
"""
project = await self.get_by_slug(slug)
if not project or not project.git_token_encrypted:
return None
try:
return decrypt_token(project.git_token_encrypted)
except EncryptionError:
self.log.error(
"Failed to decrypt git token",
project_slug=slug,
error="encryption_key_mismatch_or_corrupted",
)
raise
# =========================================================================
# ACCESS CONTROL
# =========================================================================
async def add_allowed_agent(
self,
project_id: UUID,
agent_id: UUID,
) -> ProjectTable | None:
"""
Add an agent to the allowed list for a project.
Args:
project_id: Project to update
agent_id: Agent to allow
Returns:
The updated project or None if not found
"""
project = await self.get(project_id)
if not project:
return None
# Initialize list if None (means all agents in cell allowed)
if project.allowed_agents is None:
project.allowed_agents = [agent_id]
elif agent_id not in project.allowed_agents:
project.allowed_agents = [*project.allowed_agents, agent_id]
await self.session.flush()
return project
async def remove_allowed_agent(
self,
project_id: UUID,
agent_id: UUID,
) -> ProjectTable | None:
"""
Remove an agent from the allowed list for a project.
Args:
project_id: Project to update
agent_id: Agent to remove
Returns:
The updated project or None if not found
"""
project = await self.get(project_id)
if not project or project.allowed_agents is None:
return None
if agent_id in project.allowed_agents:
project.allowed_agents = [
a for a in project.allowed_agents if a != agent_id
]
await self.session.flush()
return project
async def check_agent_access(
self,
project_id: UUID,
agent_id: UUID,
agent_team: Team,
) -> bool:
"""
Check if an agent has access to a project.
Access rules:
1. Agent must be in the project's assigned cell
2. If allowed_agents is set, agent must be in the list
3. If allowed_agents is None, all agents in the cell have access
Args:
project_id: Project to check
agent_id: Agent to check
agent_team: Agent's team/cell
Returns:
True if agent has access, False otherwise
"""
project = await self.get(project_id)
if not project:
return False
# Must be in the assigned cell
if project.assigned_cell != agent_team:
return False
# If no specific allowed list, all cell members have access
if project.allowed_agents is None:
return True
# Check the allowed list
return agent_id in project.allowed_agents
# =============================================================================
# SERVICE FACTORY
# =============================================================================
def get_project_service(session: AsyncSession) -> ProjectService:
"""Get a ProjectService instance."""
return ProjectService(session)