mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
605 lines
19 KiB
Python
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)
|