[f8480831] Batch B: extract route helpers in remaining smaller-offender route files (#760)

* [f8480831] refactor(api): extract route-layer helpers into services/schemas/utils (batch B)

Moves 28 non-@router-decorated helper functions out of 15 route files
(optimal, project, release, dashboard, pitch, x, docs, git, playbooks,
product, provider, research, secretary, system, work_session) into
their paired services module (DB/service-calling helpers), the route's
schemas module as a converter (pure response/request shaping, mirroring
the existing project_to_response/assignment_to_response pattern), or
roboco/utils/converters.py (pure generic helpers). Adds two small
shared role-check helpers to api/deps.py (require_auditor_or_ceo,
require_role_in) for endpoint-specific role gates that had no existing
home. Placement-only: no route paths, schemas, or observable behavior
changed. Fixes the handful of tests that imported the old private
helper names directly.

* [f8480831] docs(map): document Batch B route-helper relocation in api-routes-schemas.md

* [f8480831] docs(map): add Key Symbols rows for require_auditor_or_ceo/require_role_in

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
roboco-app[bot]
2026-07-31 20:23:10 +00:00
committed by GitHub
co-authored by Backend Developer 2 Backend Documenter
parent 109b4d4d82
commit 7804e0fafa
37 changed files with 778 additions and 717 deletions
+3
View File
@@ -104,6 +104,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| `require_orchestrator_ceo` | dep | api/deps.py:790 | Router-level CEO-HMAC guard on orchestrator control routes — relocated from a per-file `_require_ceo(agent)` wrapper that used to live in `routes/orchestrator.py`; `router = APIRouter(dependencies=[Depends(require_orchestrator_ceo)])`. | | `require_orchestrator_ceo` | dep | api/deps.py:790 | Router-level CEO-HMAC guard on orchestrator control routes — relocated from a per-file `_require_ceo(agent)` wrapper that used to live in `routes/orchestrator.py`; `router = APIRouter(dependencies=[Depends(require_orchestrator_ceo)])`. |
| `validate_agent_id_param` | fn | api/deps.py:839 | Path-injection guard (rejects empty/`.`/`..`/`/`/`\`/NUL) then normalizes via `_resolve_to_slug` — spawn/stop/status/resolve-wait/mark-waiting accept either a DB UUID or a slug and address the runtime container by the resolved slug; an unknown UUID passes through unchanged. Relocated from a route-local `_validated_agent_id` helper in `routes/orchestrator.py`. | | `validate_agent_id_param` | fn | api/deps.py:839 | Path-injection guard (rejects empty/`.`/`..`/`/`/`\`/NUL) then normalizes via `_resolve_to_slug` — spawn/stop/status/resolve-wait/mark-waiting accept either a DB UUID or a slug and address the runtime container by the resolved slug; an unknown UUID passes through unchanged. Relocated from a route-local `_validated_agent_id` helper in `routes/orchestrator.py`. |
| `require_ceo_role` / `require_pm_or_above` | fn | api/deps.py:627 / api/deps.py:618 | Shared role-check guards a2a.py/orchestrator.py/video.py/roadmap.py route handlers now call directly, replacing redundant per-file `_require_ceo(agent)` partial-application wrappers each of those route files used to define locally. | | `require_ceo_role` / `require_pm_or_above` | fn | api/deps.py:627 / api/deps.py:618 | Shared role-check guards a2a.py/orchestrator.py/video.py/roadmap.py route handlers now call directly, replacing redundant per-file `_require_ceo(agent)` partial-application wrappers each of those route files used to define locally. |
| `require_auditor_or_ceo` | fn | api/deps.py:666 | Auditor-or-CEO 403 gate, added in the batch-B relocation (task `f8480831`) for dashboard.py's flag/report mutations and playbooks.py's curation endpoints — the two route files' identical inline role-check collapsed into one shared `deps.py` helper. |
| `require_role_in` | fn | api/deps.py:652 | Generic "role must be a member of this set" 403 gate for an endpoint-specific role set with no standing named tier — added in the batch-B relocation (task `f8480831`), used by secretary.py's directive/state endpoints. |
| `task_to_response` / `task_list_to_response` / `finding_to_response` | fn | api/schemas/tasks.py:889,964,969 | DTO conversion helpers (`TaskTable` -> `TaskResponse`/`TaskFindingResponse`); relocated out of `routes/tasks.py` into the schema module they convert to. | | `task_to_response` / `task_list_to_response` / `finding_to_response` | fn | api/schemas/tasks.py:889,964,969 | DTO conversion helpers (`TaskTable` -> `TaskResponse`/`TaskFindingResponse`); relocated out of `routes/tasks.py` into the schema module they convert to. |
| `setup_middleware` | fn | api/middleware.py | Register exception handlers (422 scrub, HTTP, RobocoError, generic). | | `setup_middleware` | fn | api/middleware.py | Register exception handlers (422 scrub, HTTP, RobocoError, generic). |
| `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. | | `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. |
@@ -258,6 +260,7 @@ roboco/api/
> - `461a6e1a`+`96401f4c`+`5f32d876` (2026-07-18/19, forge Phases 1-4, #571/#575/#581) — no new HTTP routes (the forge routing is internal to `GitService`), but `roboco/api/schemas/project.py`/`project_fields.py` gain `git_provider` (project CRUD schemas) and the shared `task_project_fields` helper the X/video routes now call — see `docs/map/worksession-git.md` and `docs/map/product-strategy-research-pitch.md`. > - `461a6e1a`+`96401f4c`+`5f32d876` (2026-07-18/19, forge Phases 1-4, #571/#575/#581) — no new HTTP routes (the forge routing is internal to `GitService`), but `roboco/api/schemas/project.py`/`project_fields.py` gain `git_provider` (project CRUD schemas) and the shared `task_project_fields` helper the X/video routes now call — see `docs/map/worksession-git.md` and `docs/map/product-strategy-research-pitch.md`.
> - ("panel-perf-p3-p4") adds `GET /api/dashboard/metrics/members` (batch scorecard fetch) — see `docs/map/metrics-observability.md`. > - ("panel-perf-p3-p4") adds `GET /api/dashboard/metrics/members` (batch scorecard fetch) — see `docs/map/metrics-observability.md`.
> - (task `4baffaa3`, "Batch A: extract route helpers") placement-only refactor, no route/schema/behavior change: moves every non-`@router`-decorated top-level helper out of `tasks.py`, `a2a.py`, `orchestrator.py`, `video.py`, `v1/_role_dep.py`, `roadmap.py`, `prompter_live.py` (`journals.py` had none) per `.roboco/conventions.yml`'s `no_helpers_in_routes` rule — DB/side-effecting logic to the paired `roboco/services/*` module, DTO-conversion helpers to the matching `roboco/api/schemas/*.py` (e.g. `task_to_response`), and small HTTP-layer auth guards (`envelope_to_response`, `require_orchestrator_ceo`, `validate_agent_id_param`, `require_ceo_role`, `require_pm_or_above`) into `roboco/api/deps.py`, replacing several route-files' redundant local `_require_ceo(agent)` wrappers with direct calls to the shared `deps.py` guard. Two real regressions surfaced during the relocation's revision rounds and were fixed before merge: `envelope_to_response`'s "verb rejected" structlog event was dropped in the move (restored — see the Key Symbols row above), and `_auth_required()` was narrowed to a truthy-only check that silently dropped its unset-value production fallback, which would have accepted unauthenticated `X-Agent-Role: ceo` header spoofing on an unconfigured production deploy (GHSA-4f7g-w95g-5q2c) — the three-branch fallback logic was restored. > - (task `4baffaa3`, "Batch A: extract route helpers") placement-only refactor, no route/schema/behavior change: moves every non-`@router`-decorated top-level helper out of `tasks.py`, `a2a.py`, `orchestrator.py`, `video.py`, `v1/_role_dep.py`, `roadmap.py`, `prompter_live.py` (`journals.py` had none) per `.roboco/conventions.yml`'s `no_helpers_in_routes` rule — DB/side-effecting logic to the paired `roboco/services/*` module, DTO-conversion helpers to the matching `roboco/api/schemas/*.py` (e.g. `task_to_response`), and small HTTP-layer auth guards (`envelope_to_response`, `require_orchestrator_ceo`, `validate_agent_id_param`, `require_ceo_role`, `require_pm_or_above`) into `roboco/api/deps.py`, replacing several route-files' redundant local `_require_ceo(agent)` wrappers with direct calls to the shared `deps.py` guard. Two real regressions surfaced during the relocation's revision rounds and were fixed before merge: `envelope_to_response`'s "verb rejected" structlog event was dropped in the move (restored — see the Key Symbols row above), and `_auth_required()` was narrowed to a truthy-only check that silently dropped its unset-value production fallback, which would have accepted unauthenticated `X-Agent-Role: ceo` header spoofing on an unconfigured production deploy (GHSA-4f7g-w95g-5q2c) — the three-branch fallback logic was restored.
> - (task `f8480831`, "Batch B: extract route helpers in remaining smaller-offender route files") placement-only refactor, no route/schema/behavior change: moved 28 non-`@router`-decorated helper functions out of 15 of the 24 batch-B route files (`optimal.py`, `project.py`, `release.py`, `dashboard.py`, `pitch.py`, `x.py`, `docs.py`, `git.py`, `playbooks.py`, `product.py`, `provider.py`, `research.py`, `secretary.py`, `system.py`, `work_session.py`) into their paired `roboco/services/*` module (DB/service-calling helpers), the route's own `roboco/api/schemas/*.py` as a converter (pure response/request shaping, mirroring `task_to_response`), or `roboco/utils/converters.py` (pure generic helpers); the other 9 files (`notifications.py`, `agents.py`, `cockpit.py`, `company_goals.py`, `kanban.py`, `secretary_live.py`, `settings.py`, `stream.py`, `usage.py`) had zero helpers by the precise `classify_python.py` classifier already. Added two small shared role-check helpers to `roboco/api/deps.py` (`require_auditor_or_ceo`, `require_role_in`) for endpoint-specific role gates that had no existing home.
## Regression Risks ## Regression Risks
+25
View File
@@ -649,6 +649,31 @@ def require_developer_or_above(role: Any, action: str) -> None:
) )
def require_role_in(role: Any, allowed: frozenset[Role], detail: str) -> None:
"""Raise 403 unless ``role`` is a member of ``allowed``.
A generic sibling to the named ``require_*`` checks above, for callers
(e.g. the Secretary surface) that gate a single endpoint to an arbitrary,
endpoint-specific role set rather than one of the standing tiers.
"""
if role not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
_AUDITOR_OR_CEO_ROLES: frozenset[Role] = frozenset({Role.AUDITOR, Role.CEO})
def require_auditor_or_ceo(role: Any, detail: str) -> None:
"""Raise 403 unless caller is the Auditor or the CEO.
The Auditor is the silent-observer role; the CEO overrides. Shared by the
dashboard's auditor-flag/report mutations and the playbook curation
endpoints — both gate to this same role pair with their own 403 wording.
"""
if _role_value(role) not in _AUDITOR_OR_CEO_ROLES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
_GLOBAL_CELL_ACCESS_ROLES: frozenset[Role] = (BOARD_ROLES - {Role.HEAD_MARKETING}) | { _GLOBAL_CELL_ACCESS_ROLES: frozenset[Role] = (BOARD_ROLES - {Role.HEAD_MARKETING}) | {
Role.MAIN_PM, Role.MAIN_PM,
Role.CEO, Role.CEO,
+16 -19
View File
@@ -10,7 +10,12 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_panel_token from roboco.api.deps import (
CurrentAgentContext,
DbSession,
require_auditor_or_ceo,
require_panel_token,
)
from roboco.api.schemas.dashboard import ( from roboco.api.schemas.dashboard import (
AuditorDashboard, AuditorDashboard,
AuditorFlag, AuditorFlag,
@@ -22,7 +27,6 @@ from roboco.api.schemas.dashboard import (
TeamHealth, TeamHealth,
UsageSummary, UsageSummary,
) )
from roboco.models import AgentRole
from roboco.models.base import Team from roboco.models.base import Team
from roboco.models.dashboard import CreateFlagParams from roboco.models.dashboard import CreateFlagParams
from roboco.services.dashboard import get_dashboard_service from roboco.services.dashboard import get_dashboard_service
@@ -37,19 +41,12 @@ router = APIRouter(dependencies=[Depends(require_panel_token)])
# The auditor flag/report mutating routes are gated to the Auditor and the # The auditor flag/report mutating routes are gated to the Auditor and the
# CEO. The Auditor is the silent-observer role whose flags/reports feed the # CEO. The Auditor is the silent-observer role whose flags/reports feed the
# CEO; the CEO overrides. Mirrors ``_require_curator`` in playbooks.py and # CEO; the CEO overrides. Read-only auditor views (``GET /auditor/flags``,
# ``_require_ceo`` in release.py. Read-only auditor views (``GET # ``GET /auditor/reports``, ``GET /auditor``) stay open — the dashboard is
# /auditor/flags``, ``GET /auditor/reports``, ``GET /auditor``) stay open — # observable by any authenticated operator.
# the dashboard is observable by any authenticated operator. _MUTATE_FLAGS_OR_REPORTS_DETAIL = (
_AUDITOR_OR_CEO_ROLES = frozenset({AgentRole.AUDITOR, AgentRole.CEO}) "Only the Auditor or CEO may mutate auditor flags or reports"
)
def _require_auditor_or_ceo(agent: CurrentAgentContext) -> None:
if agent.role not in _AUDITOR_OR_CEO_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the Auditor or CEO may mutate auditor flags or reports",
)
# ============================================================================= # =============================================================================
@@ -162,7 +159,7 @@ async def create_auditor_flag(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> AuditorFlag: ) -> AuditorFlag:
"""Create a new auditor flag.""" """Create a new auditor flag."""
_require_auditor_or_ceo(agent) require_auditor_or_ceo(agent.role, _MUTATE_FLAGS_OR_REPORTS_DETAIL)
service = get_dashboard_service(db) service = get_dashboard_service(db)
params = CreateFlagParams( params = CreateFlagParams(
severity=data.severity.value, severity=data.severity.value,
@@ -195,7 +192,7 @@ async def resolve_auditor_flag(
notes: str | None = None, notes: str | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
"""Resolve an auditor flag.""" """Resolve an auditor flag."""
_require_auditor_or_ceo(agent) require_auditor_or_ceo(agent.role, _MUTATE_FLAGS_OR_REPORTS_DETAIL)
service = get_dashboard_service(db) service = get_dashboard_service(db)
if not service.resolve_flag(flag_id, notes): if not service.resolve_flag(flag_id, notes):
raise HTTPException( raise HTTPException(
@@ -238,7 +235,7 @@ async def create_auditor_report(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> AuditorReport: ) -> AuditorReport:
"""Create a new auditor report.""" """Create a new auditor report."""
_require_auditor_or_ceo(agent) require_auditor_or_ceo(agent.role, _MUTATE_FLAGS_OR_REPORTS_DETAIL)
service = get_dashboard_service(db) service = get_dashboard_service(db)
report = service.create_report( report = service.create_report(
report_type=data.report_type, report_type=data.report_type,
@@ -264,7 +261,7 @@ async def send_auditor_report(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> dict[str, str]: ) -> dict[str, str]:
"""Mark a report as sent to CEO.""" """Mark a report as sent to CEO."""
_require_auditor_or_ceo(agent) require_auditor_or_ceo(agent.role, _MUTATE_FLAGS_OR_REPORTS_DETAIL)
service = get_dashboard_service(db) service = get_dashboard_service(db)
if not service.send_report(report_id): if not service.send_report(report_id):
raise HTTPException( raise HTTPException(
+5 -20
View File
@@ -21,26 +21,11 @@ from roboco.api.schemas.docs import (
) )
from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError
from roboco.services.docs import WriteDocInput, get_docs_service from roboco.services.docs import WriteDocInput, get_docs_service
from roboco.services.gateway.kb_authz import docs_denial_envelope from roboco.services.gateway.kb_authz import docs_unauthorized_response
router = APIRouter() router = APIRouter()
def _unauthorized_response(err: UnauthorizedError) -> JSONResponse:
"""Render a docs-service denial as the gateway Envelope (HTTP 403).
The RBAC decision is made in ``DocsService`` (it raises
``UnauthorizedError``); this only renders that denial at the HTTP
boundary. The body is the Envelope wire-dict at top level so the agent
receives a non-null ``remediate`` instead of a bare ``detail`` string.
"""
envelope = docs_denial_envelope(err.action, err.reason)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=envelope.as_dict(),
)
# Module-level Query defaults # Module-level Query defaults
_list_task_id_query: UUID | None = Query(None, description="Filter by task ID") _list_task_id_query: UUID | None = Query(None, description="Filter by task ID")
_read_path_query: str = Query( _read_path_query: str = Query(
@@ -106,7 +91,7 @@ async def write_doc(
detail=e.message, detail=e.message,
) from e ) from e
except UnauthorizedError as e: except UnauthorizedError as e:
return _unauthorized_response(e) return docs_unauthorized_response(e)
except NotFoundError as e: except NotFoundError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -149,7 +134,7 @@ async def read_doc(
detail=e.message, detail=e.message,
) from e ) from e
except UnauthorizedError as e: except UnauthorizedError as e:
return _unauthorized_response(e) return docs_unauthorized_response(e)
except NotFoundError as e: except NotFoundError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -203,7 +188,7 @@ async def list_docs(
count=len(docs), count=len(docs),
) )
except UnauthorizedError as e: except UnauthorizedError as e:
return _unauthorized_response(e) return docs_unauthorized_response(e)
except NotFoundError as e: except NotFoundError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -243,7 +228,7 @@ async def delete_doc(
detail=e.message, detail=e.message,
) from e ) from e
except UnauthorizedError as e: except UnauthorizedError as e:
return _unauthorized_response(e) return docs_unauthorized_response(e)
except NotFoundError as e: except NotFoundError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+34 -137
View File
@@ -22,10 +22,8 @@ Workspace Structure:
""" """
from datetime import datetime from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.git import ( from roboco.api.schemas.git import (
@@ -57,7 +55,7 @@ from roboco.api.schemas.git import (
GitRebaseResponse, GitRebaseResponse,
GitStatusResponse, GitStatusResponse,
) )
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError from roboco.exceptions import GitError
from roboco.logging import get_logger from roboco.logging import get_logger
from roboco.models.base import AgentRole from roboco.models.base import AgentRole
from roboco.security import ( from roboco.security import (
@@ -65,15 +63,11 @@ from roboco.security import (
prompt_injection_validator, prompt_injection_validator,
secret_exfil_validator, secret_exfil_validator,
) )
from roboco.services.base import ( from roboco.services.base import ServiceError
NotFoundError, from roboco.services.git import get_git_service, translate_git_error
ServiceError,
UnauthorizedError,
ValidationError,
)
from roboco.services.git import get_git_service
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.task import get_task_service from roboco.services.task import get_task_service
from roboco.utils.converters import compute_file_range, parse_branch_line
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -92,40 +86,6 @@ _TranslatableError = (ServiceError, GitError)
# Cap an unbounded whole-file read so a huge file can't flood the panel. # Cap an unbounded whole-file read so a huge file can't flood the panel.
_FILE_MAX_LINES = 2000 _FILE_MAX_LINES = 2000
def _compute_file_range(
*,
total: int,
line: int | None,
context: int,
start: int | None,
end: int | None,
) -> tuple[int, int, bool]:
"""Resolve the (start, end, truncated) slice for a file-content read.
Explicit ``start``/``end`` win; else ``line`` centers a context window;
else the whole file. Whichever branch resolves the window, it is capped
at ``_FILE_MAX_LINES`` lines afterward. Returns 1-based inclusive
[start, end] and whether the slice is shorter than the file.
"""
if start is not None and end is not None:
s, e_ = start, end
elif line is not None:
s = max(1, line - context)
e_ = min(total, line + context)
else:
s, e_ = 1, total
s = max(1, min(s, total))
e_ = max(s, min(e_, total))
truncated = e_ < total
if e_ - s + 1 > _FILE_MAX_LINES:
e_ = s + _FILE_MAX_LINES - 1
truncated = True
return s, e_, truncated
# Roles permitted to rebase branches via the /rebase endpoint. # Roles permitted to rebase branches via the /rebase endpoint.
# Rebase is a history-rewriting operation that should be authorised only by # Rebase is a history-rewriting operation that should be authorised only by
# PM-level or CEO-level callers. Developers are intentionally excluded: # PM-level or CEO-level callers. Developers are intentionally excluded:
@@ -137,51 +97,6 @@ _REBASE_ALLOWED_ROLES: frozenset[AgentRole] = frozenset(
) )
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 # READ-ONLY ENDPOINTS
# ============================================================================= # =============================================================================
@@ -195,7 +110,7 @@ async def get_git_status(
_task_id: str | None = Query(default=None), _task_id: str | None = Query(default=None),
) -> GitStatusResponse: ) -> GitStatusResponse:
"""Get git status for a project.""" """Get git status for a project."""
project_slug = await _resolve_project_slug(project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -210,7 +125,7 @@ async def get_git_status(
behind, behind,
) = await git_service.get_status(workspace) ) = await git_service.get_status(workspace)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitStatusResponse( return GitStatusResponse(
project_slug=project_slug, project_slug=project_slug,
@@ -233,7 +148,7 @@ async def get_git_log(
branch: str | None = Query(default=None), branch: str | None = Query(default=None),
) -> GitLogResponse: ) -> GitLogResponse:
"""Get git log for a project.""" """Get git log for a project."""
project_slug = await _resolve_project_slug(project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -277,7 +192,7 @@ async def get_git_log(
) )
return GitLogResponse(project_slug=project_slug, branch=branch, commits=[]) return GitLogResponse(project_slug=project_slug, branch=branch, commits=[])
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
commits = [] commits = []
for line in log_result.stdout.strip().split("\n"): for line in log_result.stdout.strip().split("\n"):
@@ -302,28 +217,6 @@ async def get_git_log(
) )
def _parse_branch_line(line: str) -> tuple[str, bool, str | None] | None:
"""Classify one `%(refname)|%(objectname:short)` line as (name, is_remote,
last_commit), or None for skippable entries (blank, origin/HEAD, other ref
namespaces). Full refname, not `:short` a remote-tracking ref shortens to
`origin/<branch>`, indistinguishable from a local branch literally named
that; classify on the `refs/heads/` vs `refs/remotes/` prefix instead.
"""
if not line:
return None
parts = line.split("|")
ref = parts[0]
last_commit = parts[1] if len(parts) > 1 else None
if ref.startswith("refs/heads/"):
return ref.removeprefix("refs/heads/"), False, last_commit
if ref.startswith("refs/remotes/"):
_remote_name, _, name = ref.removeprefix("refs/remotes/").partition("/")
if not name or name == "HEAD":
return None # origin/HEAD is a symbolic pointer, not a branch
return name, True, last_commit
return None
@router.get("/branches", response_model=GitBranchListResponse) @router.get("/branches", response_model=GitBranchListResponse)
async def list_branches( async def list_branches(
db: DbSession, db: DbSession,
@@ -332,7 +225,7 @@ async def list_branches(
include_remote: bool = Query(default=False), include_remote: bool = Query(default=False),
) -> GitBranchListResponse: ) -> GitBranchListResponse:
"""List git branches for a project.""" """List git branches for a project."""
project_slug = await _resolve_project_slug(project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -350,11 +243,11 @@ async def list_branches(
branch_result = await git_service._run_git(workspace, args) branch_result = await git_service._run_git(workspace, args)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
branches = [] branches = []
for line in branch_result.stdout.strip().split("\n"): for line in branch_result.stdout.strip().split("\n"):
parsed = _parse_branch_line(line) parsed = parse_branch_line(line)
if parsed is None: if parsed is None:
continue continue
name, is_remote, last_commit = parsed name, is_remote, last_commit = parsed
@@ -383,7 +276,7 @@ async def get_git_diff(
file_path: str | None = Query(default=None), file_path: str | None = Query(default=None),
) -> GitDiffResponse: ) -> GitDiffResponse:
"""Get git diff for a project.""" """Get git diff for a project."""
project_slug = await _resolve_project_slug(project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -403,7 +296,7 @@ async def get_git_diff(
stat_args.append("--staged") stat_args.append("--staged")
stat_result = await git_service._run_git(workspace, stat_args) stat_result = await git_service._run_git(workspace, stat_args)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
files_changed = stat_result.stdout.count("\n") - 1 if stat_result.stdout else 0 files_changed = stat_result.stdout.count("\n") - 1 if stat_result.stdout else 0
@@ -444,7 +337,7 @@ async def get_git_file(
branch_name=branch, path=path, actor_agent_id=agent.agent_id branch_name=branch, path=path, actor_agent_id=agent.agent_id
) )
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
if content is None: if content is None:
raise HTTPException( raise HTTPException(
@@ -455,8 +348,12 @@ async def get_git_file(
all_lines = content.splitlines() all_lines = content.splitlines()
total = len(all_lines) total = len(all_lines)
s, e_, truncated = _compute_file_range( s, e_, truncated = compute_file_range(
total=total, line=line, context=context, start=start, end=end total=total,
line=line,
context=context,
explicit_range=(start, end) if start is not None and end is not None else None,
max_lines=_FILE_MAX_LINES,
) )
sliced = all_lines[s - 1 : e_] sliced = all_lines[s - 1 : e_]
@@ -497,7 +394,7 @@ async def create_commit(
deletions, deletions,
) = await git_service.commit_for_task(agent.agent_id, data) ) = await git_service.commit_for_task(agent.agent_id, data)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitCommitResponse( return GitCommitResponse(
commit_hash=commit_hash, commit_hash=commit_hash,
@@ -525,7 +422,7 @@ async def push_commits(
agent.agent_id, agent.role, data agent.agent_id, agent.role, data
) )
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitPushResponse( return GitPushResponse(
branch=branch, branch=branch,
@@ -555,7 +452,7 @@ async def create_branch(
agent.agent_id, data agent.agent_id, data
) )
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitCreateBranchResponse( return GitCreateBranchResponse(
branch_name=branch_name, branch_name=branch_name,
@@ -585,7 +482,7 @@ async def checkout_branch(
try: try:
await git_service.checkout_branch_for_agent(agent.agent_id, data) await git_service.checkout_branch_for_agent(agent.agent_id, data)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitCheckoutResponse( return GitCheckoutResponse(
branch=data.branch, branch=data.branch,
@@ -615,7 +512,7 @@ async def create_pull_request(
target_branch, target_branch,
) = await git_service.create_pr_for_task(agent.agent_id, data) ) = await git_service.create_pr_for_task(agent.agent_id, data)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitCreatePRResponse( return GitCreatePRResponse(
pr_number=pr_number, pr_number=pr_number,
@@ -643,7 +540,7 @@ async def merge_pull_request(
agent.agent_id, agent.role, data agent.agent_id, agent.role, data
) )
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitMergePRResponse( return GitMergePRResponse(
pr_number=data.pr_number, pr_number=data.pr_number,
@@ -664,7 +561,7 @@ async def pull_commits(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitPullResponse: ) -> GitPullResponse:
"""Pull latest changes from origin into the agent workspace.""" """Pull latest changes from origin into the agent workspace."""
project_slug = await _resolve_project_slug(data.project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(data.project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -679,7 +576,7 @@ async def pull_commits(
behind, behind,
) = await git_service.pull(workspace) ) = await git_service.pull(workspace)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitPullResponse( return GitPullResponse(
project_slug=project_slug, project_slug=project_slug,
@@ -704,7 +601,7 @@ async def fetch_commits(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GitFetchResponse: ) -> GitFetchResponse:
"""Fetch changes from origin without merging.""" """Fetch changes from origin without merging."""
project_slug = await _resolve_project_slug(data.project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(data.project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -719,7 +616,7 @@ async def fetch_commits(
behind, behind,
) = await git_service.fetch(workspace) ) = await git_service.fetch(workspace)
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitFetchResponse( return GitFetchResponse(
project_slug=project_slug, project_slug=project_slug,
@@ -782,7 +679,7 @@ async def rebase_branch(
"you. Only the task's assigned agent or CEO may rebase it." "you. Only the task's assigned agent or CEO may rebase it."
), ),
) )
project_slug = await _resolve_project_slug(data.project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(data.project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -791,7 +688,7 @@ async def rebase_branch(
workspace, data.target_branch, project_slug workspace, data.target_branch, project_slug
) )
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitRebaseResponse( return GitRebaseResponse(
project_slug=project_slug, project_slug=project_slug,
@@ -829,7 +726,7 @@ async def cleanup_stale_branches(
"main_pm) may use this endpoint." "main_pm) may use this endpoint."
), ),
) )
project_slug = await _resolve_project_slug(data.project_slug, db) project_slug = await get_project_service(db).resolve_slug_or_404(data.project_slug)
git_service = get_git_service(db) git_service = get_git_service(db)
try: try:
@@ -844,7 +741,7 @@ async def cleanup_stale_branches(
project_slug, after_task_id=data.after_cursor project_slug, after_task_id=data.after_cursor
) )
except _TranslatableError as e: except _TranslatableError as e:
raise _translate_error(e) from e raise translate_git_error(e) from e
return GitBranchCleanupResponse( return GitBranchCleanupResponse(
project_slug=project_slug, project_slug=project_slug,
+10 -32
View File
@@ -76,7 +76,7 @@ from roboco.security import (
prompt_injection_validator, prompt_injection_validator,
secret_exfil_validator, secret_exfil_validator,
) )
from roboco.services.gateway.kb_authz import authorize_kb_action from roboco.services.gateway.kb_authz import kb_denial_response
from roboco.services.optimal import ( from roboco.services.optimal import (
IndexType, IndexType,
QueryContext, QueryContext,
@@ -93,28 +93,6 @@ logger = structlog.get_logger()
router = APIRouter() router = APIRouter()
def _kb_denial_response(
permissions: PermissionServiceDep,
agent: CurrentAgentContext,
action: str,
) -> JSONResponse | None:
"""Gateway Envelope (HTTP 403) when the KB action is denied, else None.
The authorization decision itself lives in the gateway
(``authorize_kb_action``); this only renders a denial verdict at the HTTP
boundary. The body is the Envelope wire-dict at top level not nested
under ``detail`` so the agent receives a non-null ``remediate`` it can
act on, matching the gateway Envelope contract.
"""
denial = authorize_kb_action(permissions, agent, action)
if denial is None:
return None
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=denial.as_dict(),
)
# ============================================================================= # =============================================================================
# INDEXING ENDPOINTS # INDEXING ENDPOINTS
# ============================================================================= # =============================================================================
@@ -142,7 +120,7 @@ async def index_code(
- Directories - Directories
- Glob patterns (e.g., "src/**/*.py") - Glob patterns (e.g., "src/**/*.py")
""" """
denied = _kb_denial_response(permissions, agent, KBAction.INDEX_CODE) denied = kb_denial_response(permissions, agent, KBAction.INDEX_CODE)
if denied is not None: if denied is not None:
return denied return denied
@@ -181,7 +159,7 @@ async def index_documentation(
- URLs (single page or crawl with /**) - URLs (single page or crawl with /**)
- Glob patterns - Glob patterns
""" """
denied = _kb_denial_response(permissions, agent, KBAction.INDEX_DOCS) denied = kb_denial_response(permissions, agent, KBAction.INDEX_DOCS)
if denied is not None: if denied is not None:
return denied return denied
@@ -467,7 +445,7 @@ async def get_stats(
permissions: PermissionServiceDep, permissions: PermissionServiceDep,
) -> IndexStatsResponse | JSONResponse: ) -> IndexStatsResponse | JSONResponse:
"""Get statistics about all indexes.""" """Get statistics about all indexes."""
denied = _kb_denial_response(permissions, agent, KBAction.VIEW_STATS) denied = kb_denial_response(permissions, agent, KBAction.VIEW_STATS)
if denied is not None: if denied is not None:
return denied return denied
@@ -493,7 +471,7 @@ async def check_staleness(
Declared BEFORE `/stats/{index_type}` so FastAPI matches the literal Declared BEFORE `/stats/{index_type}` so FastAPI matches the literal
`staleness` segment instead of treating it as an `index_type` param. `staleness` segment instead of treating it as an `index_type` param.
""" """
denied = _kb_denial_response(permissions, agent, KBAction.VIEW_STATS) denied = kb_denial_response(permissions, agent, KBAction.VIEW_STATS)
if denied is not None: if denied is not None:
return denied return denied
@@ -508,7 +486,7 @@ async def get_single_index_stats(
permissions: PermissionServiceDep, permissions: PermissionServiceDep,
) -> SingleIndexStatsResponse | JSONResponse: ) -> SingleIndexStatsResponse | JSONResponse:
"""Get statistics for a specific index type.""" """Get statistics for a specific index type."""
denied = _kb_denial_response(permissions, agent, KBAction.VIEW_STATS) denied = kb_denial_response(permissions, agent, KBAction.VIEW_STATS)
if denied is not None: if denied is not None:
return denied return denied
@@ -564,7 +542,7 @@ async def clear_index(
Warning: This permanently deletes all documents in the index. Warning: This permanently deletes all documents in the index.
""" """
denied = _kb_denial_response(permissions, agent, KBAction.CLEAR_INDEX) denied = kb_denial_response(permissions, agent, KBAction.CLEAR_INDEX)
if denied is not None: if denied is not None:
return denied return denied
@@ -595,7 +573,7 @@ async def list_documents(
pagination: PaginationDep, pagination: PaginationDep,
) -> DocumentListResponse | JSONResponse: ) -> DocumentListResponse | JSONResponse:
"""List documents in a specific index (paginated).""" """List documents in a specific index (paginated)."""
denied = _kb_denial_response(permissions, agent, KBAction.VIEW_STATS) denied = kb_denial_response(permissions, agent, KBAction.VIEW_STATS)
if denied is not None: if denied is not None:
return denied return denied
try: try:
@@ -645,7 +623,7 @@ async def refresh_index(
Re-indexes the specified sources to pick up changes. Re-indexes the specified sources to pick up changes.
""" """
denied = _kb_denial_response(permissions, agent, KBAction.REFRESH_INDEX) denied = kb_denial_response(permissions, agent, KBAction.REFRESH_INDEX)
if denied is not None: if denied is not None:
return denied return denied
@@ -706,7 +684,7 @@ async def reindex_all(
""" """
import asyncio import asyncio
denied = _kb_denial_response(permissions, agent, KBAction.INDEX_CODE) denied = kb_denial_response(permissions, agent, KBAction.INDEX_CODE)
if denied is not None: if denied is not None:
return denied return denied
+20 -80
View File
@@ -12,17 +12,21 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.pitch import PitchCreateRequest, PitchDecision, PitchResponse from roboco.api.schemas.pitch import (
from roboco.db.tables import PitchTable PitchCreateRequest,
from roboco.foundation.identity import CELL_TEAMS, Team PitchDecision,
PitchResponse,
pitch_to_response,
)
from roboco.models import AgentRole from roboco.models import AgentRole
from roboco.models.pitch import PitchCreate, PitchStatus from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.services.base import ConflictError, NotFoundError, ValidationError from roboco.services.base import ConflictError, NotFoundError, ValidationError
from roboco.services.github_provisioning import ( from roboco.services.github_provisioning import ProvisioningError
ProvisioningDisabledError, from roboco.services.pitch import (
ProvisioningError, get_pitch_service,
parse_cell_teams,
pitch_error_to_http_exc,
) )
from roboco.services.pitch import get_pitch_service
router = APIRouter() router = APIRouter()
@@ -38,70 +42,6 @@ _VIEW_ROLES = frozenset(
) )
_SERVICE_ERROR_HTTP: tuple[tuple[type[Exception], int], ...] = (
(NotFoundError, status.HTTP_404_NOT_FOUND),
(ProvisioningDisabledError, status.HTTP_400_BAD_REQUEST),
(ProvisioningError, status.HTTP_502_BAD_GATEWAY),
(ConflictError, status.HTTP_409_CONFLICT),
(ValidationError, status.HTTP_400_BAD_REQUEST),
)
def _to_http_exc(exc: Exception) -> HTTPException:
"""Translate a known service/provisioning error into an HTTPException.
ProvisioningDisabledError is listed before ProvisioningError (its parent)
so the more specific 400 wins.
"""
detail = getattr(exc, "message", None) or str(exc)
for exc_type, code in _SERVICE_ERROR_HTTP:
if isinstance(exc, exc_type):
return HTTPException(status_code=code, detail=detail)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
)
def _to_response(pitch: PitchTable) -> PitchResponse:
return PitchResponse(
id=str(pitch.id),
title=pitch.title,
slug=pitch.slug,
problem=pitch.problem,
proposed_solution=pitch.proposed_solution,
target_cells=list(pitch.target_cells or []),
status=pitch.status,
created_by=str(pitch.created_by),
decided_by=str(pitch.decided_by) if pitch.decided_by else None,
decision_notes=pitch.decision_notes,
provisioned_product_id=(
str(pitch.provisioned_product_id) if pitch.provisioned_product_id else None
),
provisioned_project_ids=list(pitch.provisioned_project_ids or []),
seed_task_id=str(pitch.seed_task_id) if pitch.seed_task_id else None,
created_at=pitch.created_at.isoformat() if pitch.created_at else None,
)
def _parse_cells(raw: list[str]) -> list[Team]:
cells: list[Team] = []
for c in raw:
try:
team = Team(c)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"unknown cell '{c}'",
) from exc
if team not in CELL_TEAMS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"'{c}' is not a cell team",
)
cells.append(team)
return cells
@router.post("", response_model=PitchResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=PitchResponse, status_code=status.HTTP_201_CREATED)
async def create_pitch( async def create_pitch(
data: PitchCreateRequest, db: DbSession, agent: CurrentAgentContext data: PitchCreateRequest, db: DbSession, agent: CurrentAgentContext
@@ -117,15 +57,15 @@ async def create_pitch(
slug=data.slug, slug=data.slug,
problem=data.problem, problem=data.problem,
proposed_solution=data.proposed_solution, proposed_solution=data.proposed_solution,
target_cells=_parse_cells(data.target_cells), target_cells=parse_cell_teams(data.target_cells),
) )
service = get_pitch_service(db) service = get_pitch_service(db)
try: try:
pitch = await service.create(create, created_by=agent.agent_id) pitch = await service.create(create, created_by=agent.agent_id)
except ConflictError as exc: except ConflictError as exc:
raise _to_http_exc(exc) from exc raise pitch_error_to_http_exc(exc) from exc
await db.commit() await db.commit()
return _to_response(pitch) return pitch_to_response(pitch)
@router.get("", response_model=list[PitchResponse]) @router.get("", response_model=list[PitchResponse])
@@ -148,7 +88,7 @@ async def list_pitches(
detail=f"unknown pitch status '{status_filter}'", detail=f"unknown pitch status '{status_filter}'",
) from exc ) from exc
pitches = await get_pitch_service(db).list_pitches(parsed) pitches = await get_pitch_service(db).list_pitches(parsed)
return [_to_response(p) for p in pitches] return [pitch_to_response(p) for p in pitches]
@router.get("/{pitch_id}", response_model=PitchResponse) @router.get("/{pitch_id}", response_model=PitchResponse)
@@ -166,7 +106,7 @@ async def get_pitch(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="pitch not found" status_code=status.HTTP_404_NOT_FOUND, detail="pitch not found"
) )
return _to_response(pitch) return pitch_to_response(pitch)
@router.post("/{pitch_id}/approve", response_model=PitchResponse) @router.post("/{pitch_id}/approve", response_model=PitchResponse)
@@ -192,9 +132,9 @@ async def approve_pitch(
ValidationError, ValidationError,
ProvisioningError, ProvisioningError,
) as exc: ) as exc:
raise _to_http_exc(exc) from exc raise pitch_error_to_http_exc(exc) from exc
await db.commit() await db.commit()
return _to_response(pitch) return pitch_to_response(pitch)
@router.post("/{pitch_id}/reject", response_model=PitchResponse) @router.post("/{pitch_id}/reject", response_model=PitchResponse)
@@ -219,6 +159,6 @@ async def reject_pitch(
try: try:
pitch = await service.reject(pitch_id, data.notes, agent.agent_id) pitch = await service.reject(pitch_id, data.notes, agent.agent_id)
except (NotFoundError, ConflictError) as exc: except (NotFoundError, ConflictError) as exc:
raise _to_http_exc(exc) from exc raise pitch_error_to_http_exc(exc) from exc
await db.commit() await db.commit()
return _to_response(pitch) return pitch_to_response(pitch)
+6 -15
View File
@@ -9,24 +9,15 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession, require_auditor_or_ceo
from roboco.api.schemas.playbook import PlaybookRejectBody from roboco.api.schemas.playbook import PlaybookRejectBody
from roboco.models import AgentRole
from roboco.models.playbook import Playbook from roboco.models.playbook import Playbook
from roboco.services.base import ConflictError, NotFoundError from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import get_playbook_service from roboco.services.playbook import get_playbook_service
router = APIRouter() router = APIRouter()
_CURATOR_ROLES = frozenset({AgentRole.AUDITOR, AgentRole.CEO}) _CURATOR_DETAIL = "Only the Auditor or CEO may curate playbooks"
def _require_curator(agent: CurrentAgentContext) -> None:
if agent.role not in _CURATOR_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the Auditor or CEO may curate playbooks",
)
@router.get("", response_model=list[Playbook]) @router.get("", response_model=list[Playbook])
@@ -36,7 +27,7 @@ async def list_playbooks(
status_filter: str = Query(default="draft", alias="status"), status_filter: str = Query(default="draft", alias="status"),
) -> list[Playbook]: ) -> list[Playbook]:
"""List playbooks by status (default: drafts awaiting review).""" """List playbooks by status (default: drafts awaiting review)."""
_require_curator(agent) require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
svc = get_playbook_service(db) svc = get_playbook_service(db)
rows = ( rows = (
await svc.list_approved() await svc.list_approved()
@@ -51,7 +42,7 @@ async def approve_playbook(
playbook_id: UUID, db: DbSession, agent: CurrentAgentContext playbook_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> Playbook: ) -> Playbook:
"""Approve a draft playbook → approved (and indexed into the KB).""" """Approve a draft playbook → approved (and indexed into the KB)."""
_require_curator(agent) require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
try: try:
svc = get_playbook_service(db) svc = get_playbook_service(db)
playbook = await svc.approve(playbook_id, approver_id=agent.agent_id) playbook = await svc.approve(playbook_id, approver_id=agent.agent_id)
@@ -81,7 +72,7 @@ async def reject_playbook(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> Playbook: ) -> Playbook:
"""Reject a draft playbook → archived, with the Auditor's reason.""" """Reject a draft playbook → archived, with the Auditor's reason."""
_require_curator(agent) require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
try: try:
svc = get_playbook_service(db) svc = get_playbook_service(db)
playbook = await svc.reject( playbook = await svc.reject(
@@ -106,7 +97,7 @@ async def archive_playbook(
playbook_id: UUID, db: DbSession, agent: CurrentAgentContext playbook_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> Playbook: ) -> Playbook:
"""Retire an approved playbook → archived (and de-indexed from the KB).""" """Retire an approved playbook → archived (and de-indexed from the KB)."""
_require_curator(agent) require_auditor_or_ceo(agent.role, _CURATOR_DETAIL)
try: try:
svc = get_playbook_service(db) svc = get_playbook_service(db)
playbook = await svc.archive(playbook_id, approver_id=agent.agent_id) playbook = await svc.archive(playbook_id, approver_id=agent.agent_id)
+6 -7
View File
@@ -15,20 +15,17 @@ from roboco.api.schemas.product import (
ProductResponse, ProductResponse,
ProductSummaryResponse, ProductSummaryResponse,
ProductUpdateRequest, ProductUpdateRequest,
cell_mappings_from_request,
product_to_response, product_to_response,
product_to_summary, product_to_summary,
) )
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate from roboco.models.product import ProductCreate, ProductUpdate
from roboco.services.base import ConflictError from roboco.services.base import ConflictError
from roboco.services.product import get_product_service from roboco.services.product import get_product_service
router = APIRouter() router = APIRouter()
def _to_mappings(cells: list) -> list[ProductCellMapping]:
return [ProductCellMapping(team=c.team, project_id=c.project_id) for c in cells]
@router.get("", response_model=list[ProductSummaryResponse]) @router.get("", response_model=list[ProductSummaryResponse])
async def list_products( async def list_products(
db: DbSession, db: DbSession,
@@ -68,7 +65,7 @@ async def create_product(
name=data.name, name=data.name,
slug=data.slug, slug=data.slug,
description=data.description, description=data.description,
cells=_to_mappings(data.cells), cells=cell_mappings_from_request(data.cells),
) )
# The service raises ConflictError (slug already taken) before any flush. # The service raises ConflictError (slug already taken) before any flush.
# Replacing cells then flushes child rows that can violate the # Replacing cells then flushes child rows that can violate the
@@ -128,7 +125,9 @@ async def update_product(
update_data = ProductUpdate( update_data = ProductUpdate(
name=data.name, name=data.name,
description=data.description, description=data.description,
cells=_to_mappings(data.cells) if data.cells is not None else None, cells=cell_mappings_from_request(data.cells)
if data.cells is not None
else None,
) )
# Replacing cells flushes child rows that can violate the # Replacing cells flushes child rows that can violate the
# uq_product_projects_product_team UNIQUE (two cells with the same team in # uq_product_projects_product_team UNIQUE (two cells with the same team in
+10 -37
View File
@@ -4,14 +4,11 @@ Project API Routes
CRUD operations for managing git projects/repositories. CRUD operations for managing git projects/repositories.
""" """
from typing import TYPE_CHECKING, Annotated, cast from typing import Annotated, cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
if TYPE_CHECKING:
from roboco.db.tables import ProjectTable
from roboco.api.deps import ( from roboco.api.deps import (
CurrentAgentContext, CurrentAgentContext,
DbSession, DbSession,
@@ -30,17 +27,15 @@ from roboco.api.schemas.project import (
ProjectUpdateRequest, ProjectUpdateRequest,
SetWorkspaceRequest, SetWorkspaceRequest,
SyncStateRequest, SyncStateRequest,
conventions_action_to_response,
project_to_response, project_to_response,
project_to_summary, project_to_summary,
) )
from roboco.foundation.policy.conventions.models import ConventionsStandard from roboco.foundation.policy.conventions.models import ConventionsStandard
from roboco.models.base import Team from roboco.models.base import Team
from roboco.models.project import ProjectCreate, ProjectUpdate from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.conventions import ( from roboco.services.conventions import get_conventions_service
ScaffoldResult, from roboco.services.project import get_project_service
get_conventions_service,
)
from roboco.services.project import ProjectService, get_project_service
router = APIRouter() router = APIRouter()
@@ -469,28 +464,6 @@ async def remove_agent_access(
# ============================================================================= # =============================================================================
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) @router.get("/{project_id}/conventions", response_model=ConventionsResponse)
async def get_conventions( async def get_conventions(
project_id: str, project_id: str,
@@ -498,7 +471,7 @@ async def get_conventions(
_agent: CurrentAgentContext, _agent: CurrentAgentContext,
) -> ConventionsResponse: ) -> ConventionsResponse:
"""Return the project's effective conventions map + its current health.""" """Return the project's effective conventions map + its current health."""
project = await _get_project_or_404(get_project_service(db), project_id) project = await get_project_service(db).get_by_id_or_slug_or_404(project_id)
conv = get_conventions_service(db) conv = get_conventions_service(db)
# Ensure a default-branch read clone once, then read the map + health from # 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 # it. This is the backfill: a project created before the standard existed
@@ -526,10 +499,10 @@ async def update_conventions(
) -> ConventionsActionResponse: ) -> ConventionsActionResponse:
"""Commit an edited conventions standard back to the repo via a PR (PM+).""" """Commit an edited conventions standard back to the repo via a PR (PM+)."""
require_pm_or_above(agent.role, "edit conventions") require_pm_or_above(agent.role, "edit conventions")
project = await _get_project_or_404(get_project_service(db), project_id) project = await get_project_service(db).get_by_id_or_slug_or_404(project_id)
result = await get_conventions_service(db).commit_standard(project, standard) result = await get_conventions_service(db).commit_standard(project, standard)
await db.commit() await db.commit()
return _action_response(result) return conventions_action_to_response(result)
@router.post( @router.post(
@@ -542,10 +515,10 @@ async def restore_conventions(
) -> ConventionsActionResponse: ) -> ConventionsActionResponse:
"""Re-commit the conventions file from the last-good map via a PR (PM+).""" """Re-commit the conventions file from the last-good map via a PR (PM+)."""
require_pm_or_above(agent.role, "restore conventions") require_pm_or_above(agent.role, "restore conventions")
project = await _get_project_or_404(get_project_service(db), project_id) project = await get_project_service(db).get_by_id_or_slug_or_404(project_id)
result = await get_conventions_service(db).restore(project) result = await get_conventions_service(db).restore(project)
await db.commit() await db.commit()
return _action_response(result) return conventions_action_to_response(result)
@router.get( @router.get(
@@ -559,7 +532,7 @@ async def get_conventions_findings(
limit: Annotated[int, Query(ge=1, le=200)] = 50, limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[ConventionFinding]: ) -> list[ConventionFinding]:
"""Recent architectural-conventions findings for the project (violations feed).""" """Recent architectural-conventions findings for the project (violations feed)."""
project = await _get_project_or_404(get_project_service(db), project_id) project = await get_project_service(db).get_by_id_or_slug_or_404(project_id)
rows = await get_conventions_service(db).recent_findings( rows = await get_conventions_service(db).recent_findings(
UUID(str(project.id)), limit UUID(str(project.id)), limit
) )
+4 -58
View File
@@ -33,6 +33,8 @@ from roboco.api.schemas.provider import (
SetGrokKeyRequest, SetGrokKeyRequest,
SetOllamaKeyRequest, SetOllamaKeyRequest,
assignment_to_response, assignment_to_response,
parse_complexity_override,
provider_remediation,
routing_preset_to_summary, routing_preset_to_summary,
) )
from roboco.billing.pricing import input_price_per_million from roboco.billing.pricing import input_price_per_million
@@ -59,42 +61,6 @@ _COMPLEXITY_OVERRIDE_ROLES: frozenset[str] = frozenset(
{"developer", "qa", "documenter"} {"developer", "qa", "documenter"}
) )
# Human remediation hint per provider type, for a complexity override that
# resolves to a not-ready (disabled / unconfigured) provider.
_PROVIDER_REMEDIATION: dict[ModelProvider, str] = {
ModelProvider.GROK: "Save the Grok (xAI) API key first (PUT /providers/grok-key).",
ModelProvider.OLLAMA_CLOUD: (
"Save an Ollama Cloud API key first (PUT /providers/ollama-key)."
),
ModelProvider.LOCAL: (
"Configure + test the self-hosted server first (PUT /providers/self-hosted)."
),
ModelProvider.ANTHROPIC: "The Anthropic provider is disabled — re-enable it first.",
ModelProvider.OPENAI: (
"Codex authenticates via a mounted ChatGPT-subscription ~/.codex "
"directory, not a key — enable it via the Codex mode button, or "
"assign a Codex model to an agent in Mix mode (both force-enable "
"the row)."
),
ModelProvider.GEMINI: (
"Gemini authenticates via a mounted OAuth ~/.gemini credential, not "
"a key — enable it via the Gemini mode button, or assign a Gemini "
"model to an agent in Mix mode (both force-enable the row)."
),
ModelProvider.KIMI: (
"Kimi authenticates via a shared, symlinked-in ~/.kimi-code "
"subscription credential, not a key — enable it via the Kimi mode "
"button, or assign a Kimi model to an agent in Mix mode (both "
"force-enable the row)."
),
}
def _provider_remediation(provider_type: ModelProvider) -> str:
return _PROVIDER_REMEDIATION.get(
provider_type, f"The {provider_type.value} provider is not configured."
)
# ============================================================================= # =============================================================================
# CATALOG # CATALOG
@@ -474,26 +440,6 @@ async def apply_mode(
# ============================================================================= # =============================================================================
def _parse_complexity_override(
scope_value: str, model_name: str
) -> ComplexityOverrideResponse | None:
"""Parse a ROLE scope_value into a response row, or None if not a
well-formed "role:low"/"role:high" compound key (a plain role row, or a
malformed compound value, are both silently skipped)."""
role, sep, complexity = scope_value.partition(":")
if not sep or not role:
return None
if complexity == "low":
return ComplexityOverrideResponse(
role=role, complexity="low", model_name=model_name
)
if complexity == "high":
return ComplexityOverrideResponse(
role=role, complexity="high", model_name=model_name
)
return None
@router.get("/complexity-overrides", response_model=list[ComplexityOverrideResponse]) @router.get("/complexity-overrides", response_model=list[ComplexityOverrideResponse])
async def get_complexity_overrides( async def get_complexity_overrides(
db: DbSession, db: DbSession,
@@ -507,7 +453,7 @@ async def get_complexity_overrides(
for row in rows: for row in rows:
if row.scope != AssignmentScope.ROLE or not row.scope_value: if row.scope != AssignmentScope.ROLE or not row.scope_value:
continue continue
parsed = _parse_complexity_override(row.scope_value, row.model_name) parsed = parse_complexity_override(row.scope_value, row.model_name)
if parsed is not None: if parsed is not None:
overrides.append(parsed) overrides.append(parsed)
return overrides return overrides
@@ -582,7 +528,7 @@ async def set_complexity_override(
if not provider.enabled or ( if not provider.enabled or (
provider.type == ModelProvider.LOCAL and not provider.base_url provider.type == ModelProvider.LOCAL and not provider.base_url
): ):
remediation = _provider_remediation(provider.type) remediation = provider_remediation(provider.type)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=( detail=(
+7 -46
View File
@@ -6,7 +6,7 @@ cancels the proposal (freeing the one-open dedup for a fresh re-assessment).
Nothing here publishes without the CEO's explicit POST. Nothing here publishes without the CEO's explicit POST.
""" """
from typing import TYPE_CHECKING, cast from typing import cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -15,70 +15,31 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.release import ( from roboco.api.schemas.release import (
ReleaseExecuteResponse, ReleaseExecuteResponse,
ReleaseGapModel,
ReleaseProposalResponse, ReleaseProposalResponse,
ReleaseRejectRequest, ReleaseRejectRequest,
ReleaseReportModel,
) )
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.release_proposal import ( from roboco.services.release_proposal import (
dispatch_approve, dispatch_approve,
get_release_proposal_service, get_release_proposal_service,
is_approve_in_flight, task_to_proposal_response,
) )
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
router = APIRouter() router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or act on release proposals")
def _status_value(task: "TaskTable") -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def _to_response(task: "TaskTable") -> ReleaseProposalResponse:
report = markers.get_release_report(task) or {}
outcome = markers.get_release_execute_outcome(task)
return ReleaseProposalResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
required_changes=markers.get_release_required_changes(task),
execute_status=outcome[0] if outcome else None,
execute_detail=outcome[1] if outcome else None,
execute_in_flight=is_approve_in_flight(UUID(str(task.id))),
report=ReleaseReportModel(
proposed_version=report.get("proposed_version", ""),
bump_kind=report.get("bump_kind", ""),
change_summary=report.get("change_summary", []),
drafted_changelog=report.get("drafted_changelog", ""),
version_bump_plan=report.get("version_bump_plan", []),
gaps=[ReleaseGapModel(**gap) for gap in report.get("gaps", [])],
migration_notes=report.get("migration_notes", []),
gate_state=report.get("gate_state", "unknown"),
),
)
@router.get("/proposal", response_model=ReleaseProposalResponse) @router.get("/proposal", response_model=ReleaseProposalResponse)
async def get_release_proposal( async def get_release_proposal(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> ReleaseProposalResponse: ) -> ReleaseProposalResponse:
"""The single held release proposal awaiting the CEO (404 when none).""" """The single held release proposal awaiting the CEO (404 when none)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on release proposals")
task = await get_release_proposal_service(db).open_proposal() task = await get_release_proposal_service(db).open_proposal()
if task is None: if task is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No open release proposal" status_code=status.HTTP_404_NOT_FOUND, detail="No open release proposal"
) )
return _to_response(task) return task_to_proposal_response(task)
@router.post( @router.post(
@@ -103,7 +64,7 @@ async def approve_release_proposal(
published/already_published, else the proposal stays open for retry). A published/already_published, else the proposal stays open for retry). A
second click is refused by the Redis mutex (``already_in_progress``). second click is refused by the Redis mutex (``already_in_progress``).
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on release proposals")
svc = get_release_proposal_service(db) svc = get_release_proposal_service(db)
task = await svc.open_proposal() task = await svc.open_proposal()
if task is None: if task is None:
@@ -141,7 +102,7 @@ async def reject_release_proposal(
) -> ReleaseProposalResponse: ) -> ReleaseProposalResponse:
"""Reject the held proposal with required changes; it is cancelled so the """Reject the held proposal with required changes; it is cancelled so the
release manager re-assesses and may originate a fresh proposal next cycle.""" release manager re-assesses and may originate a fresh proposal next cycle."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on release proposals")
svc = get_release_proposal_service(db) svc = get_release_proposal_service(db)
task = await svc.open_proposal() task = await svc.open_proposal()
if task is None: if task is None:
@@ -160,4 +121,4 @@ async def reject_release_proposal(
), ),
) )
await db.commit() await db.commit()
return _to_response(revised) return task_to_proposal_response(revised)
+17 -27
View File
@@ -28,7 +28,7 @@ from roboco.services.research import (
ResearchUnsupportedError, ResearchUnsupportedError,
get_research_service, get_research_service,
) )
from roboco.services.research_quota import ResearchQuotaTracker from roboco.services.research_quota import ResearchQuotaTracker, enforce_research_quota
router = APIRouter() router = APIRouter()
@@ -48,28 +48,6 @@ RESEARCH_ROLES = frozenset(
_quota_tracker = ResearchQuotaTracker() _quota_tracker = ResearchQuotaTracker()
def _require_research_role(agent: CurrentAgentContext) -> None:
if agent.role not in RESEARCH_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' may not use web research",
)
async def _enforce_quota(agent: CurrentAgentContext) -> None:
result = await _quota_tracker.check_and_consume(
str(agent.agent_id), settings.research_daily_quota_per_agent
)
if not result.allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"daily research quota exhausted "
f"({result.limit}/day, resets {result.day} 24:00 UTC)"
),
)
@router.post("/search", response_model=SearchResponse) @router.post("/search", response_model=SearchResponse)
@guard_deco.rate_limit(requests=20, window=60) @guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536) @guard_deco.max_request_size(size_bytes=65536)
@@ -80,8 +58,14 @@ async def research_search(
data: SearchRequest, agent: CurrentAgentContext data: SearchRequest, agent: CurrentAgentContext
) -> SearchResponse: ) -> SearchResponse:
"""Search the public web via the configured provider (Board + PM only).""" """Search the public web via the configured provider (Board + PM only)."""
_require_research_role(agent) if agent.role not in RESEARCH_ROLES:
await _enforce_quota(agent) raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' may not use web research",
)
await enforce_research_quota(
_quota_tracker, str(agent.agent_id), settings.research_daily_quota_per_agent
)
service = get_research_service() service = get_research_service()
try: try:
outcome = await service.search(data.query, data.max_results) outcome = await service.search(data.query, data.max_results)
@@ -118,8 +102,14 @@ async def research_fetch(
data: FetchRequest, agent: CurrentAgentContext data: FetchRequest, agent: CurrentAgentContext
) -> FetchResponse: ) -> FetchResponse:
"""Extract readable content for a URL via the provider (Board + PM only).""" """Extract readable content for a URL via the provider (Board + PM only)."""
_require_research_role(agent) if agent.role not in RESEARCH_ROLES:
await _enforce_quota(agent) raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' may not use web research",
)
await enforce_research_quota(
_quota_tracker, str(agent.agent_id), settings.research_daily_quota_per_agent
)
service = get_research_service() service = get_research_service()
try: try:
outcome = await service.fetch(data.url, data.max_chars) outcome = await service.fetch(data.url, data.max_chars)
+18 -16
View File
@@ -10,7 +10,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession, require_role_in
from roboco.api.schemas.secretary import ( from roboco.api.schemas.secretary import (
CompanyStateResponse, CompanyStateResponse,
DirectiveDecision, DirectiveDecision,
@@ -26,20 +26,16 @@ from roboco.services.secretary import get_secretary_service
router = APIRouter() router = APIRouter()
_SECRETARY_OR_CEO = frozenset({AgentRole.SECRETARY, AgentRole.CEO}) _SECRETARY_OR_CEO = frozenset({AgentRole.SECRETARY, AgentRole.CEO})
_CEO_ONLY = frozenset({AgentRole.CEO})
_SURFACE_DETAIL = "role '{role}' not permitted on the Secretary surface"
def _require(agent: CurrentAgentContext, allowed: frozenset[AgentRole]) -> None:
if agent.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' not permitted on the Secretary surface",
)
@router.get("/state", response_model=CompanyStateResponse) @router.get("/state", response_model=CompanyStateResponse)
async def read_state(db: DbSession, agent: CurrentAgentContext) -> CompanyStateResponse: async def read_state(db: DbSession, agent: CurrentAgentContext) -> CompanyStateResponse:
"""Compact company-state snapshot (Secretary or CEO).""" """Compact company-state snapshot (Secretary or CEO)."""
_require(agent, _SECRETARY_OR_CEO) require_role_in(
agent.role, _SECRETARY_OR_CEO, _SURFACE_DETAIL.format(role=agent.role)
)
state = await get_secretary_service(db).read_company_state() state = await get_secretary_service(db).read_company_state()
return CompanyStateResponse(**state) return CompanyStateResponse(**state)
@@ -56,7 +52,9 @@ async def search_tasks(
The CEO refers to tasks by NAME in the Secretary chat; this resolves a The CEO refers to tasks by NAME in the Secretary chat; this resolves a
name to concrete ids so a directive can target the right task. name to concrete ids so a directive can target the right task.
""" """
_require(agent, _SECRETARY_OR_CEO) require_role_in(
agent.role, _SECRETARY_OR_CEO, _SURFACE_DETAIL.format(role=agent.role)
)
from roboco.services.task import get_task_service from roboco.services.task import get_task_service
rows = await get_task_service(db).search_tasks(q, limit=limit) rows = await get_task_service(db).search_tasks(q, limit=limit)
@@ -78,7 +76,9 @@ async def read_task(
) -> dict[str, object]: ) -> dict[str, object]:
"""Read one task's full detail — content, notes, plan, progress, PR ref """Read one task's full detail — content, notes, plan, progress, PR ref
(Secretary or CEO). Secretary FULL task access.""" (Secretary or CEO). Secretary FULL task access."""
_require(agent, _SECRETARY_OR_CEO) require_role_in(
agent.role, _SECRETARY_OR_CEO, _SURFACE_DETAIL.format(role=agent.role)
)
try: try:
return await get_secretary_service(db).read_task(task_id) return await get_secretary_service(db).read_task(task_id)
except NotFoundError as exc: except NotFoundError as exc:
@@ -101,7 +101,9 @@ async def submit_directive(
data: DirectiveSubmit, db: DbSession, agent: CurrentAgentContext data: DirectiveSubmit, db: DbSession, agent: CurrentAgentContext
) -> DirectiveResponse: ) -> DirectiveResponse:
"""Submit a directive (Secretary or CEO). Gated kinds queue; others run.""" """Submit a directive (Secretary or CEO). Gated kinds queue; others run."""
_require(agent, _SECRETARY_OR_CEO) require_role_in(
agent.role, _SECRETARY_OR_CEO, _SURFACE_DETAIL.format(role=agent.role)
)
try: try:
kind = DirectiveKind(data.kind) kind = DirectiveKind(data.kind)
except ValueError as exc: except ValueError as exc:
@@ -125,7 +127,7 @@ async def list_directives(
db: DbSession, agent: CurrentAgentContext, status_filter: str | None = None db: DbSession, agent: CurrentAgentContext, status_filter: str | None = None
) -> list[DirectiveResponse]: ) -> list[DirectiveResponse]:
"""List directives (CEO only); optional status filter.""" """List directives (CEO only); optional status filter."""
_require(agent, frozenset({AgentRole.CEO})) require_role_in(agent.role, _CEO_ONLY, _SURFACE_DETAIL.format(role=agent.role))
parsed: DirectiveStatus | None = None parsed: DirectiveStatus | None = None
if status_filter: if status_filter:
try: try:
@@ -147,7 +149,7 @@ async def confirm_directive(
directive_id: UUID, db: DbSession, agent: CurrentAgentContext directive_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> DirectiveResponse: ) -> DirectiveResponse:
"""CEO confirms a pending directive — it executes with CEO authority.""" """CEO confirms a pending directive — it executes with CEO authority."""
_require(agent, frozenset({AgentRole.CEO})) require_role_in(agent.role, _CEO_ONLY, _SURFACE_DETAIL.format(role=agent.role))
service = get_secretary_service(db) service = get_secretary_service(db)
try: try:
row = await service.confirm_directive(directive_id, agent.agent_id) row = await service.confirm_directive(directive_id, agent.agent_id)
@@ -174,7 +176,7 @@ async def reject_directive(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> DirectiveResponse: ) -> DirectiveResponse:
"""CEO rejects a pending directive.""" """CEO rejects a pending directive."""
_require(agent, frozenset({AgentRole.CEO})) require_role_in(agent.role, _CEO_ONLY, _SURFACE_DETAIL.format(role=agent.role))
service = get_secretary_service(db) service = get_secretary_service(db)
try: try:
row = await service.reject_directive(directive_id, agent.agent_id, data.reason) row = await service.reject_directive(directive_id, agent.agent_id, data.reason)
+2 -15
View File
@@ -13,28 +13,15 @@ Currently exposed:
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from roboco.api.deps import require_panel_token from roboco.api.deps import require_panel_token
from roboco.api.schemas.system import RateLimitEntry, RateLimitListResponse from roboco.api.schemas.system import RateLimitEntry, RateLimitListResponse
from roboco.services.gateway.rate_limit_tracker import RateLimitStateTracker from roboco.services.gateway.rate_limit_tracker import RateLimitStateTracker, resume_at
router = APIRouter(dependencies=[Depends(require_panel_token)]) router = APIRouter(dependencies=[Depends(require_panel_token)])
def _resume_at(hit_at: str | None, retry_after: float | None) -> str | None:
"""Estimated lift time = hit_at + retry_after, ISO; falls back to hit_at."""
if not hit_at or retry_after is None:
return hit_at
try:
lifted = datetime.fromisoformat(hit_at) + timedelta(seconds=retry_after)
except (ValueError, TypeError):
return hit_at
return lifted.isoformat()
@router.get( @router.get(
"/rate-limits", "/rate-limits",
summary="List per-provider rate-limit state", summary="List per-provider rate-limit state",
@@ -58,7 +45,7 @@ async def get_rate_limits() -> RateLimitListResponse:
provider=provider, provider=provider,
affected_agents=state.get("affected_agents", []), affected_agents=state.get("affected_agents", []),
hit_at=state.get("activated_at"), hit_at=state.get("activated_at"),
resume_at=_resume_at(state.get("activated_at"), state.get("retry_after")), resume_at=resume_at(state.get("activated_at"), state.get("retry_after")),
retry_after_seconds=state.get("retry_after"), retry_after_seconds=state.get("retry_after"),
) )
for provider, state in states for provider, state in states
+11 -56
View File
@@ -26,60 +26,15 @@ from roboco.api.schemas.work_session import (
session_to_response, session_to_response,
session_to_summary, session_to_summary,
) )
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.models.work_session import WorkSessionCreate, WorkSessionStatus from roboco.models.work_session import WorkSessionCreate, WorkSessionStatus
from roboco.services.work_session import WorkSessionService, get_work_session_service from roboco.services.work_session import (
assert_session_ownership,
get_work_session_service,
)
router = APIRouter() router = APIRouter()
# =============================================================================
# OWNERSHIP GUARD
#
# Every mutating route keys off session_id alone, so without a re-check any
# developer could mutate a peer's session and any PM could merge any cell's PR
# — bypassing the verb layer's active-claimant gate. Re-assert the caller owns
# the session (dev ops) or owns the session's task cell (PM ops) before the
# service call (#158).
# =============================================================================
async def _assert_ownership(
service: WorkSessionService,
session_id: UUID,
agent: AgentContext,
*,
pm_op: bool,
) -> None:
"""Fetch the session and verify the caller may mutate it.
Raises 404 for a missing session, 403 for a wrong-owner / wrong-cell caller.
Dev ops require the caller to BE the session's agent. PM ops (merge_pr)
require a cell PM to own the session's task cell; main PM / CEO / board
coordinate every cell and are admitted by the role gate alone.
"""
session = await service.get(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Work session not found: {session_id}",
)
if pm_op:
if agent.role == AgentRole.CELL_PM:
team = await service.task_team_for_session(session_id)
if agent.team is None or team is None or team != agent.team:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="cell PM does not own this session's task cell",
)
elif session.agent_id != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not the owner of this work session",
)
# ============================================================================= # =============================================================================
# LIST & GET ENDPOINTS # LIST & GET ENDPOINTS
# ============================================================================= # =============================================================================
@@ -213,7 +168,7 @@ async def add_commit(
require_developer_or_above(agent.role, "add commits") require_developer_or_above(agent.role, "add commits")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=False) await assert_session_ownership(service, session_id, agent, pm_op=False)
session = await service.add_commit(session_id, data.commit_sha) session = await service.add_commit(session_id, data.commit_sha)
await db.commit() await db.commit()
@@ -238,7 +193,7 @@ async def add_files_modified(
require_developer_or_above(agent.role, "add files") require_developer_or_above(agent.role, "add files")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=False) await assert_session_ownership(service, session_id, agent, pm_op=False)
session = await service.add_files_modified(session_id, data.file_paths) session = await service.add_files_modified(session_id, data.file_paths)
await db.commit() await db.commit()
@@ -268,7 +223,7 @@ async def create_pr(
require_developer_or_above(agent.role, "create PRs") require_developer_or_above(agent.role, "create PRs")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=False) await assert_session_ownership(service, session_id, agent, pm_op=False)
session = await service.create_pr(session_id, data.pr_number, data.pr_url) session = await service.create_pr(session_id, data.pr_number, data.pr_url)
await db.commit() await db.commit()
@@ -293,7 +248,7 @@ async def update_pr_status(
require_developer_or_above(agent.role, "update PR status") require_developer_or_above(agent.role, "update PR status")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=False) await assert_session_ownership(service, session_id, agent, pm_op=False)
session = await service.update_pr_status(session_id, data.pr_status) session = await service.update_pr_status(session_id, data.pr_status)
await db.commit() await db.commit()
@@ -323,7 +278,7 @@ async def merge_pr(
require_pm_or_above(agent.role, "merge PRs") require_pm_or_above(agent.role, "merge PRs")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=True) await assert_session_ownership(service, session_id, agent, pm_op=True)
session = await service.merge_pr(session_id, agent.agent_id) session = await service.merge_pr(session_id, agent.agent_id)
await db.commit() await db.commit()
@@ -352,7 +307,7 @@ async def complete_session(
require_developer_or_above(agent.role, "complete sessions") require_developer_or_above(agent.role, "complete sessions")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=False) await assert_session_ownership(service, session_id, agent, pm_op=False)
session = await service.complete(session_id) session = await service.complete(session_id)
await db.commit() await db.commit()
@@ -377,7 +332,7 @@ async def abandon_session(
require_developer_or_above(agent.role, "abandon sessions") require_developer_or_above(agent.role, "abandon sessions")
service = get_work_session_service(db) service = get_work_session_service(db)
await _assert_ownership(service, session_id, agent, pm_op=False) await assert_session_ownership(service, session_id, agent, pm_op=False)
session = await service.abandon(session_id, reason=reason) session = await service.abandon(session_id, reason=reason)
await db.commit() await db.commit()
+11 -80
View File
@@ -2,27 +2,22 @@
credentials. CEO-only throughout. Nothing here posts except an explicit credentials. CEO-only throughout. Nothing here posts except an explicit
``approve``; credentials are write-only (the API never returns plaintext).""" ``approve``; credentials are write-only (the API never returns plaintext)."""
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.project_fields import task_project_fields
from roboco.api.schemas.x import ( from roboco.api.schemas.x import (
XBarflyRefModel,
XCampaignRefModel,
XCredentialsSetRequest, XCredentialsSetRequest,
XCredentialsStatus, XCredentialsStatus,
XFeatureRefModel,
XMentionRefModel,
XPostApproveRequest, XPostApproveRequest,
XPostExecuteResponse, XPostExecuteResponse,
XPostHistoryResponse, XPostHistoryResponse,
XPostRejectRequest, XPostRejectRequest,
XPostResponse, XPostResponse,
task_to_post_history_response,
task_to_post_response,
) )
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.x_credentials import ( from roboco.services.x_credentials import (
XCredentialsValidationError, XCredentialsValidationError,
@@ -30,81 +25,17 @@ from roboco.services.x_credentials import (
) )
from roboco.services.x_post_service import XPostBodyTooLongError, get_x_post_service from roboco.services.x_post_service import XPostBodyTooLongError, get_x_post_service
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
router = APIRouter() router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or act on the X engine queue")
def _status_value(task: "TaskTable") -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def _to_response(task: "TaskTable") -> XPostResponse:
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
campaign = markers.get_x_campaign_ref(task)
barfly = markers.get_barfly_reply_ref(task)
project_slug, project_name = task_project_fields(task)
return XPostResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_status_value(task),
body=body,
char_count=len(body),
release_version=markers.get_x_release_version(task),
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
campaign=XCampaignRefModel(**campaign) if campaign else None,
barfly=XBarflyRefModel(**barfly) if barfly else None,
reject_reason=markers.get_x_reject_reason(task),
project_slug=project_slug,
project_name=project_name,
)
@router.get("/posts", response_model=list[XPostResponse]) @router.get("/posts", response_model=list[XPostResponse])
async def list_x_posts( async def list_x_posts(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> list[XPostResponse]: ) -> list[XPostResponse]:
"""Every held X draft (release posts + mention replies) awaiting the CEO.""" """Every held X draft (release posts + mention replies) awaiting the CEO."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the X engine queue")
tasks = await get_x_post_service(db).list_open_posts() tasks = await get_x_post_service(db).list_open_posts()
return [_to_response(t) for t in tasks] return [task_to_post_response(t) for t in tasks]
def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
campaign = markers.get_x_campaign_ref(task)
barfly = markers.get_barfly_reply_ref(task)
project_slug, project_name = task_project_fields(task)
return XPostHistoryResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_status_value(task),
body=body,
char_count=len(body),
release_version=markers.get_x_release_version(task),
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
campaign=XCampaignRefModel(**campaign) if campaign else None,
barfly=XBarflyRefModel(**barfly) if barfly else None,
tweet_id=markers.get_x_posted_tweet_id(task),
reject_reason=markers.get_x_reject_reason(task),
acted_at=task.updated_at or task.created_at,
project_slug=project_slug,
project_name=project_name,
)
@router.get("/posts/history", response_model=list[XPostHistoryResponse]) @router.get("/posts/history", response_model=list[XPostHistoryResponse])
@@ -114,9 +45,9 @@ async def list_x_post_history(
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
) -> list[XPostHistoryResponse]: ) -> list[XPostHistoryResponse]:
"""Posted or rejected X drafts, newest-acted-first, bounded by `limit`.""" """Posted or rejected X drafts, newest-acted-first, bounded by `limit`."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the X engine queue")
tasks = await get_x_post_service(db).list_post_history(limit=limit) tasks = await get_x_post_service(db).list_post_history(limit=limit)
return [_to_history_response(t) for t in tasks] return [task_to_post_history_response(t) for t in tasks]
@router.post("/posts/{task_id}/approve", response_model=XPostExecuteResponse) @router.post("/posts/{task_id}/approve", response_model=XPostExecuteResponse)
@@ -135,7 +66,7 @@ async def approve_x_post(
Idempotent: approving an already-posted draft returns ``already_posted`` Idempotent: approving an already-posted draft returns ``already_posted``
without calling the X API again. without calling the X API again.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the X engine queue")
svc = get_x_post_service(db) svc = get_x_post_service(db)
try: try:
result = await svc.approve(task_id, data.edited_body) result = await svc.approve(task_id, data.edited_body)
@@ -165,7 +96,7 @@ async def reject_x_post(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> XPostResponse: ) -> XPostResponse:
"""Decline the draft with a reason; it is cancelled (never posted).""" """Decline the draft with a reason; it is cancelled (never posted)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the X engine queue")
svc = get_x_post_service(db) svc = get_x_post_service(db)
task = await svc.reject(task_id, data.reason) task = await svc.reject(task_id, data.reason)
if task is None: if task is None:
@@ -173,7 +104,7 @@ async def reject_x_post(
status_code=status.HTTP_404_NOT_FOUND, detail="No such open X draft" status_code=status.HTTP_404_NOT_FOUND, detail="No such open X draft"
) )
await db.commit() await db.commit()
return _to_response(task) return task_to_post_response(task)
@router.get("/credentials", response_model=XCredentialsStatus) @router.get("/credentials", response_model=XCredentialsStatus)
@@ -181,7 +112,7 @@ async def get_x_credentials(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> XCredentialsStatus: ) -> XCredentialsStatus:
"""Whether the four OAuth 1.0a secrets are stored. Never the secrets.""" """Whether the four OAuth 1.0a secrets are stored. Never the secrets."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the X engine queue")
has_creds = await get_x_credentials_service(db).has_credentials() has_creds = await get_x_credentials_service(db).has_credentials()
return XCredentialsStatus(has_credentials=has_creds) return XCredentialsStatus(has_credentials=has_creds)
@@ -197,7 +128,7 @@ async def set_x_credentials(
data: XCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext data: XCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext
) -> XCredentialsStatus: ) -> XCredentialsStatus:
"""Set (or, passing all four empty, clear) the four OAuth 1.0a secrets.""" """Set (or, passing all four empty, clear) the four OAuth 1.0a secrets."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the X engine queue")
svc = get_x_credentials_service(db) svc = get_x_credentials_service(db)
try: try:
has_creds = await svc.set_credentials( has_creds = await svc.set_credentials(
+27
View File
@@ -1,7 +1,12 @@
"""Pitch API schemas — Board proposals and CEO decisions.""" """Pitch API schemas — Board proposals and CEO decisions."""
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
if TYPE_CHECKING:
from roboco.db.tables import PitchTable
class PitchCreateRequest(BaseModel): class PitchCreateRequest(BaseModel):
"""Board authors a pitch.""" """Board authors a pitch."""
@@ -36,3 +41,25 @@ class PitchResponse(BaseModel):
provisioned_project_ids: list[str] provisioned_project_ids: list[str]
seed_task_id: str | None = None seed_task_id: str | None = None
created_at: str | None = None created_at: str | None = None
def pitch_to_response(pitch: "PitchTable") -> PitchResponse:
"""Convert a PitchTable to PitchResponse."""
return PitchResponse(
id=str(pitch.id),
title=pitch.title,
slug=pitch.slug,
problem=pitch.problem,
proposed_solution=pitch.proposed_solution,
target_cells=list(pitch.target_cells or []),
status=pitch.status,
created_by=str(pitch.created_by),
decided_by=str(pitch.decided_by) if pitch.decided_by else None,
decision_notes=pitch.decision_notes,
provisioned_product_id=(
str(pitch.provisioned_product_id) if pitch.provisioned_product_id else None
),
provisioned_project_ids=list(pitch.provisioned_project_ids or []),
seed_task_id=str(pitch.seed_task_id) if pitch.seed_task_id else None,
created_at=pitch.created_at.isoformat() if pitch.created_at else None,
)
+6
View File
@@ -8,6 +8,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from roboco.foundation.identity import Team from roboco.foundation.identity import Team
from roboco.models.product import ProductCellMapping
if TYPE_CHECKING: if TYPE_CHECKING:
from roboco.db.tables import ProductTable from roboco.db.tables import ProductTable
@@ -84,6 +85,11 @@ class ProductUpdateRequest(BaseModel):
cells: list[CellMapping] | None = None cells: list[CellMapping] | None = None
def cell_mappings_from_request(cells: list[CellMapping]) -> list[ProductCellMapping]:
"""Convert request-body cell mappings into the service-layer model."""
return [ProductCellMapping(team=c.team, project_id=c.project_id) for c in cells]
def product_to_response(product: "ProductTable") -> ProductResponse: def product_to_response(product: "ProductTable") -> ProductResponse:
return ProductResponse( return ProductResponse(
id=typing_cast("UUID", product.id), id=typing_cast("UUID", product.id),
+10
View File
@@ -15,6 +15,7 @@ from roboco.models.base import Team
if TYPE_CHECKING: if TYPE_CHECKING:
from roboco.db.tables import ProjectTable from roboco.db.tables import ProjectTable
from roboco.services.conventions import ScaffoldResult
# ============================================================================= # =============================================================================
@@ -327,6 +328,15 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
) )
def conventions_action_to_response(
result: "ScaffoldResult",
) -> ConventionsActionResponse:
"""Convert a conventions scaffold/restore/save ``ScaffoldResult``."""
return ConventionsActionResponse(
pr_number=result.pr_number, branch=result.branch, created=result.created
)
def project_to_summary( def project_to_summary(
project: "ProjectTable", project: "ProjectTable",
task_counts: "ProjectTaskCounts | None" = None, task_counts: "ProjectTaskCounts | None" = None,
+59 -1
View File
@@ -18,13 +18,51 @@ from uuid import UUID # noqa: TC003 (pydantic needs the type at runtime)
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.models.base import AssignmentScope, ModelProvider # noqa: TC001 from roboco.models.base import AssignmentScope, ModelProvider
from roboco.utils.converters import require_uuid from roboco.utils.converters import require_uuid
if TYPE_CHECKING: if TYPE_CHECKING:
from roboco.db.tables import ModelAssignmentTable, RoutingPresetTable from roboco.db.tables import ModelAssignmentTable, RoutingPresetTable
# Human remediation hint per provider type, for a complexity override that
# resolves to a not-ready (disabled / unconfigured) provider.
_PROVIDER_REMEDIATION: dict[ModelProvider, str] = {
ModelProvider.GROK: "Save the Grok (xAI) API key first (PUT /providers/grok-key).",
ModelProvider.OLLAMA_CLOUD: (
"Save an Ollama Cloud API key first (PUT /providers/ollama-key)."
),
ModelProvider.LOCAL: (
"Configure + test the self-hosted server first (PUT /providers/self-hosted)."
),
ModelProvider.ANTHROPIC: "The Anthropic provider is disabled — re-enable it first.",
ModelProvider.OPENAI: (
"Codex authenticates via a mounted ChatGPT-subscription ~/.codex "
"directory, not a key — enable it via the Codex mode button, or "
"assign a Codex model to an agent in Mix mode (both force-enable "
"the row)."
),
ModelProvider.GEMINI: (
"Gemini authenticates via a mounted OAuth ~/.gemini credential, not "
"a key — enable it via the Gemini mode button, or assign a Gemini "
"model to an agent in Mix mode (both force-enable the row)."
),
ModelProvider.KIMI: (
"Kimi authenticates via a shared, symlinked-in ~/.kimi-code "
"subscription credential, not a key — enable it via the Kimi mode "
"button, or assign a Kimi model to an agent in Mix mode (both "
"force-enable the row)."
),
}
def provider_remediation(provider_type: ModelProvider) -> str:
"""Human remediation hint for a not-ready (disabled/unconfigured) provider."""
return _PROVIDER_REMEDIATION.get(
provider_type, f"The {provider_type.value} provider is not configured."
)
# ============================================================================= # =============================================================================
# CATALOG # CATALOG
# ============================================================================= # =============================================================================
@@ -276,6 +314,26 @@ class ComplexityOverrideResponse(BaseModel):
warning: str | None = None warning: str | None = None
def parse_complexity_override(
scope_value: str, model_name: str
) -> ComplexityOverrideResponse | None:
"""Parse a ROLE scope_value into a response row, or None if not a
well-formed "role:low"/"role:high" compound key (a plain role row, or a
malformed compound value, are both silently skipped)."""
role, sep, complexity = scope_value.partition(":")
if not sep or not role:
return None
if complexity == "low":
return ComplexityOverrideResponse(
role=role, complexity="low", model_name=model_name
)
if complexity == "high":
return ComplexityOverrideResponse(
role=role, complexity="high", model_name=model_name
)
return None
# ============================================================================= # =============================================================================
# ROUTING PRESETS (named, full snapshots of the routing state) # ROUTING PRESETS (named, full snapshots of the routing state)
# ============================================================================= # =============================================================================
+66
View File
@@ -1,11 +1,17 @@
"""Schemas for the X (Twitter) engine's CEO surface.""" """Schemas for the X (Twitter) engine's CEO surface."""
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.api.schemas.project_fields import task_project_fields
from roboco.foundation.policy.content import markers
from roboco.services.x_client import MAX_TWEET_CHARS from roboco.services.x_client import MAX_TWEET_CHARS
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class XMentionRefModel(BaseModel): class XMentionRefModel(BaseModel):
"""The mention a held reply answers.""" """The mention a held reply answers."""
@@ -61,6 +67,38 @@ class XPostResponse(BaseModel):
project_name: str | None = None project_name: str | None = None
def _task_status_value(task: "TaskTable") -> str:
"""Render a task's status as a plain string, enum or raw value alike."""
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_post_response(task: "TaskTable") -> XPostResponse:
"""Render a held/open X-draft task as the CEO-facing queue entry."""
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
campaign = markers.get_x_campaign_ref(task)
barfly = markers.get_barfly_reply_ref(task)
project_slug, project_name = task_project_fields(task)
return XPostResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_task_status_value(task),
body=body,
char_count=len(body),
release_version=markers.get_x_release_version(task),
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
campaign=XCampaignRefModel(**campaign) if campaign else None,
barfly=XBarflyRefModel(**barfly) if barfly else None,
reject_reason=markers.get_x_reject_reason(task),
project_slug=project_slug,
project_name=project_name,
)
class XPostApproveRequest(BaseModel): class XPostApproveRequest(BaseModel):
"""Approve a draft, optionally overwriting the body first.""" """Approve a draft, optionally overwriting the body first."""
@@ -102,6 +140,34 @@ class XPostHistoryResponse(BaseModel):
project_name: str | None = None project_name: str | None = None
def task_to_post_history_response(task: "TaskTable") -> XPostHistoryResponse:
"""Render a posted/rejected X-draft task as the CEO-facing history entry."""
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
campaign = markers.get_x_campaign_ref(task)
barfly = markers.get_barfly_reply_ref(task)
project_slug, project_name = task_project_fields(task)
return XPostHistoryResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_task_status_value(task),
body=body,
char_count=len(body),
release_version=markers.get_x_release_version(task),
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
campaign=XCampaignRefModel(**campaign) if campaign else None,
barfly=XBarflyRefModel(**barfly) if barfly else None,
tweet_id=markers.get_x_posted_tweet_id(task),
reject_reason=markers.get_x_reject_reason(task),
acted_at=task.updated_at or task.created_at,
project_slug=project_slug,
project_name=project_name,
)
class XCredentialsStatus(BaseModel): class XCredentialsStatus(BaseModel):
"""Whether the four OAuth 1.0a secrets are stored. Never the secrets themselves.""" """Whether the four OAuth 1.0a secrets are stored. Never the secrets themselves."""
+41
View File
@@ -17,11 +17,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import status
from fastapi.responses import JSONResponse
from roboco.models.permissions import KB_PERMISSIONS from roboco.models.permissions import KB_PERMISSIONS
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
if TYPE_CHECKING: if TYPE_CHECKING:
from roboco.models.permissions import AgentContext from roboco.models.permissions import AgentContext
from roboco.services.base import UnauthorizedError
from roboco.services.permissions import PermissionService from roboco.services.permissions import PermissionService
@@ -62,6 +66,28 @@ def authorize_kb_action(
) )
def kb_denial_response(
permissions: PermissionService,
agent: AgentContext,
action: str,
) -> JSONResponse | None:
"""Gateway Envelope (HTTP 403) when the KB action is denied, else None.
The authorization decision itself lives in ``authorize_kb_action``; this
only renders a denial verdict at the HTTP boundary. The body is the
Envelope wire-dict at top level not nested under ``detail`` so the
agent receives a non-null ``remediate`` it can act on, matching the
gateway Envelope contract.
"""
denial = authorize_kb_action(permissions, agent, action)
if denial is None:
return None
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=denial.as_dict(),
)
_DOCS_WRITE_ACTIONS = frozenset({"write_doc", "delete_doc"}) _DOCS_WRITE_ACTIONS = frozenset({"write_doc", "delete_doc"})
@@ -88,3 +114,18 @@ def docs_denial_envelope(action: str, reason: str | None) -> Envelope:
message=reason or f"not authorized: {action}", message=reason or f"not authorized: {action}",
remediate=remediate, remediate=remediate,
) )
def docs_unauthorized_response(err: UnauthorizedError) -> JSONResponse:
"""Render a docs-service denial as the gateway Envelope (HTTP 403).
The RBAC decision is made in ``DocsService`` (it raises
``UnauthorizedError``); this only renders that denial at the HTTP
boundary. The body is the Envelope wire-dict at top level so the agent
receives a non-null ``remediate`` instead of a bare ``detail`` string.
"""
envelope = docs_denial_envelope(err.action, err.reason)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=envelope.as_dict(),
)
+12 -1
View File
@@ -10,7 +10,7 @@ cross-reconnection persistence requirement.
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import redis.asyncio as redis import redis.asyncio as redis
@@ -85,6 +85,17 @@ return 0
""" """
def resume_at(hit_at: str | None, retry_after: float | None) -> str | None:
"""Estimated lift time = hit_at + retry_after, ISO; falls back to hit_at."""
if not hit_at or retry_after is None:
return hit_at
try:
lifted = datetime.fromisoformat(hit_at) + timedelta(seconds=retry_after)
except (ValueError, TypeError):
return hit_at
return lifted.isoformat()
class RateLimitStateTracker: class RateLimitStateTracker:
"""Track rate-limit state for a single AI provider in Redis. """Track rate-limit state for a single AI provider in Redis.
+22
View File
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID from uuid import UUID
import httpx import httpx
from fastapi import HTTPException, status
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine from collections.abc import Coroutine
@@ -376,6 +377,27 @@ def _select_ci_head_run(runs: list[dict[str, Any]]) -> dict[str, Any]:
return max(same_head, key=lambda r: int(r.get("run_attempt") or 0)) return max(same_head, key=lambda r: int(r.get("run_attempt") or 0))
def translate_git_error(e: ServiceError | GitError) -> HTTPException:
"""Translate a git-route service/git error into an HTTPException."""
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
)
@dataclass(frozen=True) @dataclass(frozen=True)
class _CiRunQuery: class _CiRunQuery:
"""Bundle of per-project inputs to a CI-run fetch (repo ref, branch, token, """Bundle of per-project inputs to a CI-run fetch (repo ref, branch, token,
+44 -1
View File
@@ -21,11 +21,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from roboco.config import settings from roboco.config import settings
from roboco.db.tables import PitchTable from roboco.db.tables import PitchTable
from roboco.foundation.identity import Team from roboco.foundation.identity import CELL_TEAMS, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.pitch import PitchCreate, PitchStatus from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate
@@ -40,6 +41,7 @@ from roboco.services.base import (
) )
from roboco.services.github_provisioning import ( from roboco.services.github_provisioning import (
ProvisioningDisabledError, ProvisioningDisabledError,
ProvisioningError,
get_github_provisioning_service, get_github_provisioning_service,
) )
from roboco.services.product import get_product_service from roboco.services.product import get_product_service
@@ -56,6 +58,47 @@ if TYPE_CHECKING:
_DESCRIPTION_CAP = 500 _DESCRIPTION_CAP = 500
# Known pitch-flow exceptions, in priority order (ProvisioningDisabledError
# before its parent ProvisioningError so the more specific 400 wins).
_SERVICE_ERROR_HTTP: tuple[tuple[type[Exception], int], ...] = (
(NotFoundError, status.HTTP_404_NOT_FOUND),
(ProvisioningDisabledError, status.HTTP_400_BAD_REQUEST),
(ProvisioningError, status.HTTP_502_BAD_GATEWAY),
(ConflictError, status.HTTP_409_CONFLICT),
(ValidationError, status.HTTP_400_BAD_REQUEST),
)
def pitch_error_to_http_exc(exc: Exception) -> HTTPException:
"""Translate a known pitch service/provisioning error into an HTTPException."""
detail = getattr(exc, "message", None) or str(exc)
for exc_type, code in _SERVICE_ERROR_HTTP:
if isinstance(exc, exc_type):
return HTTPException(status_code=code, detail=detail)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
)
def parse_cell_teams(raw: list[str]) -> list[Team]:
"""Validate + convert pitch ``target_cells`` strings into ``Team`` members."""
cells: list[Team] = []
for c in raw:
try:
team = Team(c)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"unknown cell '{c}'",
) from exc
if team not in CELL_TEAMS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"'{c}' is not a cell team",
)
cells.append(team)
return cells
class PitchService(BaseService): class PitchService(BaseService):
"""CRUD + approve/reject for Board pitches.""" """CRUD + approve/reject for Board pitches."""
+28
View File
@@ -9,6 +9,7 @@ from typing import ClassVar
from typing import cast as typing_cast from typing import cast as typing_cast
from uuid import UUID from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -210,6 +211,33 @@ class ProjectService(BaseService):
raise NotFoundError("Project", str(project_id)) raise NotFoundError("Project", str(project_id))
return project return project
async def get_by_id_or_slug_or_404(self, project_id: str) -> ProjectTable:
"""Resolve a project by UUID or slug, raising HTTP 404 when absent.
Used by the conventions endpoints, which accept either form in the
``project_id`` path parameter.
"""
try:
project = await self.get(UUID(project_id))
except ValueError:
project = await self.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
async def resolve_slug_or_404(self, identifier: str) -> 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. Verifies the project exists and returns the
canonical slug so downstream git-service calls work.
"""
project = await self.get_by_id_or_slug_or_404(identifier)
return str(project.slug)
async def update( async def update(
self, self,
project_id: UUID, project_id: UUID,
+36
View File
@@ -18,6 +18,11 @@ from uuid import uuid4
import redis.asyncio as redis import redis.asyncio as redis
from roboco.api.schemas.release import (
ReleaseGapModel,
ReleaseProposalResponse,
ReleaseReportModel,
)
from roboco.config import settings from roboco.config import settings
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus from roboco.models.base import TaskStatus
@@ -635,6 +640,37 @@ def is_approve_in_flight(task_id: UUID) -> bool:
return task_id in _INFLIGHT_APPROVES return task_id in _INFLIGHT_APPROVES
def _task_status_value(task: TaskTable) -> str:
"""Render a task's status as a plain string, enum or raw value alike."""
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_proposal_response(task: TaskTable) -> ReleaseProposalResponse:
"""Render a held release-proposal task as the CEO-facing response shape."""
report = markers.get_release_report(task) or {}
outcome = markers.get_release_execute_outcome(task)
return ReleaseProposalResponse(
task_id=str(task.id),
title=task.title,
status=_task_status_value(task),
required_changes=markers.get_release_required_changes(task),
execute_status=outcome[0] if outcome else None,
execute_detail=outcome[1] if outcome else None,
execute_in_flight=is_approve_in_flight(cast("UUID", task.id)),
report=ReleaseReportModel(
proposed_version=report.get("proposed_version", ""),
bump_kind=report.get("bump_kind", ""),
change_summary=report.get("change_summary", []),
drafted_changelog=report.get("drafted_changelog", ""),
version_bump_plan=report.get("version_bump_plan", []),
gaps=[ReleaseGapModel(**gap) for gap in report.get("gaps", [])],
migration_notes=report.get("migration_notes", []),
gate_state=report.get("gate_state", "unknown"),
),
)
def dispatch_approve( def dispatch_approve(
task_id: UUID, session_factory: async_sessionmaker[AsyncSession] task_id: UUID, session_factory: async_sessionmaker[AsyncSession]
) -> asyncio.Task[None]: ) -> asyncio.Task[None]:
+16
View File
@@ -13,6 +13,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
import redis.asyncio as redis import redis.asyncio as redis
from fastapi import HTTPException, status
from roboco.config import settings from roboco.config import settings
@@ -76,3 +77,18 @@ class ResearchQuotaTracker:
if self._redis is not None: if self._redis is not None:
await self._redis.aclose() await self._redis.aclose()
self._redis = None self._redis = None
async def enforce_research_quota(
tracker: ResearchQuotaTracker, agent_id: str, daily_quota: int
) -> None:
"""Consume one unit of ``agent_id``'s daily research quota, or raise 429."""
result = await tracker.check_and_consume(agent_id, daily_quota)
if not result.allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"daily research quota exhausted "
f"({result.limit}/day, resets {result.day} 24:00 UTC)"
),
)
+52 -2
View File
@@ -6,15 +6,16 @@ WorkSessions track branch management, commits, and PR lifecycle.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, ClassVar, cast from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import and_, select from sqlalchemy import and_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable, TaskTable, WorkSessionTable from roboco.db.tables import ProjectTable, TaskTable, WorkSessionTable
from roboco.models import Team from roboco.models import AgentRole, Team
from roboco.models.work_session import ( from roboco.models.work_session import (
WorkSessionCreate, WorkSessionCreate,
WorkSessionStatus, WorkSessionStatus,
@@ -27,6 +28,9 @@ from roboco.services.base import (
ValidationError, ValidationError,
) )
if TYPE_CHECKING:
from roboco.models.permissions import AgentContext
class WorkSessionService(BaseService): class WorkSessionService(BaseService):
""" """
@@ -711,6 +715,52 @@ class WorkSessionService(BaseService):
return work_session.pr_number is None return work_session.pr_number is None
# =============================================================================
# OWNERSHIP GUARD
#
# Every mutating route keys off session_id alone, so without a re-check any
# developer could mutate a peer's session and any PM could merge any cell's PR
# — bypassing the verb layer's active-claimant gate. Re-assert the caller owns
# the session (dev ops) or owns the session's task cell (PM ops) before the
# service call (#158).
# =============================================================================
async def assert_session_ownership(
service: WorkSessionService,
session_id: UUID,
agent: "AgentContext",
*,
pm_op: bool,
) -> None:
"""Fetch the session and verify the caller may mutate it.
Raises 404 for a missing session, 403 for a wrong-owner / wrong-cell caller.
Dev ops require the caller to BE the session's agent. PM ops (merge_pr)
require a cell PM to own the session's task cell; main PM / CEO / board
coordinate every cell and are admitted by the role gate alone.
"""
session = await service.get(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Work session not found: {session_id}",
)
if pm_op:
if agent.role == AgentRole.CELL_PM:
team = await service.task_team_for_session(session_id)
if agent.team is None or team is None or team != agent.team:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="cell PM does not own this session's task cell",
)
elif session.agent_id != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not the owner of this work session",
)
# ============================================================================= # =============================================================================
# SERVICE FACTORY # SERVICE FACTORY
# ============================================================================= # =============================================================================
+55
View File
@@ -78,6 +78,61 @@ def to_python_uuid(value: Any) -> PythonUUID | None:
return PythonUUID(str(value)) return PythonUUID(str(value))
def compute_file_range(
*,
total: int,
line: int | None,
context: int,
explicit_range: tuple[int, int] | None,
max_lines: int,
) -> tuple[int, int, bool]:
"""Resolve the (start, end, truncated) slice for a file-content read.
An explicit ``explicit_range`` (start, end) wins; else ``line`` centers a
context window; else the whole file. Whichever branch resolves the
window, it is capped at ``max_lines`` lines afterward. Returns 1-based
inclusive [start, end] and whether the slice is shorter than the file.
"""
if explicit_range is not None:
s, e_ = explicit_range
elif line is not None:
s = max(1, line - context)
e_ = min(total, line + context)
else:
s, e_ = 1, total
s = max(1, min(s, total))
e_ = max(s, min(e_, total))
truncated = e_ < total
if e_ - s + 1 > max_lines:
e_ = s + max_lines - 1
truncated = True
return s, e_, truncated
def parse_branch_line(line: str) -> tuple[str, bool, str | None] | None:
"""Classify one `%(refname)|%(objectname:short)` line as (name, is_remote,
last_commit), or None for skippable entries (blank, origin/HEAD, other ref
namespaces). Full refname, not `:short` a remote-tracking ref shortens to
`origin/<branch>`, indistinguishable from a local branch literally named
that; classify on the `refs/heads/` vs `refs/remotes/` prefix instead.
"""
if not line:
return None
parts = line.split("|")
ref = parts[0]
last_commit = parts[1] if len(parts) > 1 else None
if ref.startswith("refs/heads/"):
return ref.removeprefix("refs/heads/"), False, last_commit
if ref.startswith("refs/remotes/"):
_remote_name, _, name = ref.removeprefix("refs/remotes/").partition("/")
if not name or name == "HEAD":
return None # origin/HEAD is a symbolic pointer, not a branch
return name, True, last_commit
return None
def to_python_uuid_list(values: list[Any] | None) -> list[PythonUUID]: def to_python_uuid_list(values: list[Any] | None) -> list[PythonUUID]:
""" """
Convert list of SQLAlchemy UUIDs to Python UUIDs. Convert list of SQLAlchemy UUIDs to Python UUIDs.
+6 -6
View File
@@ -14,7 +14,6 @@ import pytest_asyncio
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.git import _translate_error
from roboco.api.routes.git import router as git_router from roboco.api.routes.git import router as git_router
from roboco.db.tables import AgentTable, ProjectTable from roboco.db.tables import AgentTable, ProjectTable
from roboco.exceptions import GitCommandError, GitTimeoutError from roboco.exceptions import GitCommandError, GitTimeoutError
@@ -26,6 +25,7 @@ from roboco.services.base import (
UnauthorizedError, UnauthorizedError,
ValidationError, ValidationError,
) )
from roboco.services.git import translate_git_error
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator, AsyncIterator from collections.abc import AsyncGenerator, AsyncIterator
@@ -215,23 +215,23 @@ async def test_status_not_found(git_client: dict) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_git_timeout_directly() -> None: async def test_status_git_timeout_directly() -> None:
"""Exercise _translate_error's GitTimeoutError branch directly. """Exercise translate_git_error's GitTimeoutError branch directly.
The route uses `except ServiceError as e` from services.base, but The route uses `except ServiceError as e` from services.base, but
GitTimeoutError extends roboco.exceptions.ServiceError (different GitTimeoutError extends roboco.exceptions.ServiceError (different
class), so it never enters _translate_error in practice. We invoke class), so it never enters translate_git_error in practice. We invoke
the helper directly to cover the branch. the helper directly to cover the branch.
""" """
err = GitTimeoutError("git status", 10) err = GitTimeoutError("git status", 10)
http_exc = _translate_error(err) http_exc = translate_git_error(err)
assert http_exc.status_code == HTTPStatus.GATEWAY_TIMEOUT assert http_exc.status_code == HTTPStatus.GATEWAY_TIMEOUT
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_git_command_error_directly() -> None: async def test_status_git_command_error_directly() -> None:
"""Direct invocation of _translate_error's GitCommandError branch.""" """Direct invocation of translate_git_error's GitCommandError branch."""
err = GitCommandError("git status", "stderr") err = GitCommandError("git status", "stderr")
http_exc = _translate_error(err) http_exc = translate_git_error(err)
assert http_exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR assert http_exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
@@ -1,13 +1,13 @@
"""Unit tests: _resolve_project_slug accepts slug or UUID.""" """Unit tests: ProjectService.resolve_slug_or_404 accepts slug or UUID."""
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from roboco.api.routes.git import _resolve_project_slug from roboco.services.project import ProjectService
_HTTP_404 = 404 _HTTP_404 = 404
@@ -24,15 +24,15 @@ def _make_project(slug: str, uid: UUID) -> MagicMock:
async def test_resolve_project_slug_accepts_slug() -> None: async def test_resolve_project_slug_accepts_slug() -> None:
"""A plain slug string resolves to the project's slug.""" """A plain slug string resolves to the project's slug."""
project = _make_project("roboco", uuid4()) project = _make_project("roboco", uuid4())
mock_service = MagicMock() service = ProjectService(MagicMock())
mock_service.get_by_slug = AsyncMock(return_value=project) service.get_by_slug = AsyncMock(return_value=project) # type: ignore[method-assign]
service.get = AsyncMock() # type: ignore[method-assign]
with patch("roboco.api.routes.git.get_project_service", return_value=mock_service): result = await service.resolve_slug_or_404("roboco")
result = await _resolve_project_slug("roboco", MagicMock())
assert result == "roboco" assert result == "roboco"
mock_service.get_by_slug.assert_awaited_once_with("roboco") service.get_by_slug.assert_awaited_once_with("roboco")
mock_service.get.assert_not_called() service.get.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -40,28 +40,25 @@ async def test_resolve_project_slug_accepts_uuid() -> None:
"""A UUID string resolves to the project's slug.""" """A UUID string resolves to the project's slug."""
uid = uuid4() uid = uuid4()
project = _make_project("roboco", uid) project = _make_project("roboco", uid)
mock_service = MagicMock() service = ProjectService(MagicMock())
mock_service.get = AsyncMock(return_value=project) service.get = AsyncMock(return_value=project) # type: ignore[method-assign]
service.get_by_slug = AsyncMock() # type: ignore[method-assign]
with patch("roboco.api.routes.git.get_project_service", return_value=mock_service): result = await service.resolve_slug_or_404(str(uid))
result = await _resolve_project_slug(str(uid), MagicMock())
assert result == "roboco" assert result == "roboco"
mock_service.get.assert_awaited_once_with(UUID(str(uid))) service.get.assert_awaited_once_with(UUID(str(uid)))
mock_service.get_by_slug.assert_not_called() service.get_by_slug.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_project_slug_raises_404_for_missing_slug() -> None: async def test_resolve_project_slug_raises_404_for_missing_slug() -> None:
"""Unknown slug raises HTTPException 404.""" """Unknown slug raises HTTPException 404."""
mock_service = MagicMock() service = ProjectService(MagicMock())
mock_service.get_by_slug = AsyncMock(return_value=None) service.get_by_slug = AsyncMock(return_value=None) # type: ignore[method-assign]
with ( with pytest.raises(HTTPException) as exc_info:
patch("roboco.api.routes.git.get_project_service", return_value=mock_service), await service.resolve_slug_or_404("nonexistent")
pytest.raises(HTTPException) as exc_info,
):
await _resolve_project_slug("nonexistent", MagicMock())
assert exc_info.value.status_code == _HTTP_404 assert exc_info.value.status_code == _HTTP_404
assert "nonexistent" in exc_info.value.detail assert "nonexistent" in exc_info.value.detail
@@ -71,14 +68,11 @@ async def test_resolve_project_slug_raises_404_for_missing_slug() -> None:
async def test_resolve_project_slug_raises_404_for_missing_uuid() -> None: async def test_resolve_project_slug_raises_404_for_missing_uuid() -> None:
"""UUID that matches no project raises HTTPException 404.""" """UUID that matches no project raises HTTPException 404."""
uid = uuid4() uid = uuid4()
mock_service = MagicMock() service = ProjectService(MagicMock())
mock_service.get = AsyncMock(return_value=None) service.get = AsyncMock(return_value=None) # type: ignore[method-assign]
with ( with pytest.raises(HTTPException) as exc_info:
patch("roboco.api.routes.git.get_project_service", return_value=mock_service), await service.resolve_slug_or_404(str(uid))
pytest.raises(HTTPException) as exc_info,
):
await _resolve_project_slug(str(uid), MagicMock())
assert exc_info.value.status_code == _HTTP_404 assert exc_info.value.status_code == _HTTP_404
assert str(uid) in exc_info.value.detail assert str(uid) in exc_info.value.detail
+10 -10
View File
@@ -1,9 +1,9 @@
"""``roboco/api/routes/x.py`` response-builder wiring for project_slug/ """``roboco/api/schemas/x.py`` response-builder wiring for project_slug/
project_name. The sa_inspect(task).unloaded guard branches themselves are project_name. The sa_inspect(task).unloaded guard branches themselves are
covered once on the shared helper in tests/unit/api/schemas/test_project_fields.py covered once on the shared helper in tests/unit/api/schemas/test_project_fields.py
this only asserts _to_response/_to_history_response actually populate this only asserts task_to_post_response/task_to_post_history_response
the response from it (loaded case; a real ORM task always resolves the actually populate the response from it (loaded case; a real ORM task always
"loaded" branch since ``project`` is lazy="joined").""" resolves the "loaded" branch since ``project`` is lazy="joined")."""
from __future__ import annotations from __future__ import annotations
@@ -11,11 +11,11 @@ from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from roboco.api.routes.x import _to_history_response, _to_response from roboco.api.schemas.x import task_to_post_history_response, task_to_post_response
def _stub_task(*, with_project: bool = False) -> Any: def _stub_task(*, with_project: bool = False) -> Any:
"""A TaskTable stand-in matching _to_response/_to_history_response's reads.""" """A TaskTable stand-in matching the response builders' reads."""
return SimpleNamespace( return SimpleNamespace(
id="task-1", id="task-1",
source="x_post", source="x_post",
@@ -44,7 +44,7 @@ def test_to_response_includes_project_fields_when_loaded() -> None:
"roboco.api.schemas.project_fields.sa_inspect", "roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(), return_value=_loaded_inspector(),
): ):
resp = _to_response(_stub_task(with_project=True)) resp = task_to_post_response(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics" assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics" assert resp.project_name == "Acme Robotics"
@@ -54,7 +54,7 @@ def test_to_response_omits_project_fields_when_project_unset() -> None:
"roboco.api.schemas.project_fields.sa_inspect", "roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(), return_value=_loaded_inspector(),
): ):
resp = _to_response(_stub_task(with_project=False)) resp = task_to_post_response(_stub_task(with_project=False))
assert resp.project_slug is None assert resp.project_slug is None
assert resp.project_name is None assert resp.project_name is None
@@ -64,7 +64,7 @@ def test_to_history_response_includes_project_fields_when_loaded() -> None:
"roboco.api.schemas.project_fields.sa_inspect", "roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(), return_value=_loaded_inspector(),
): ):
resp = _to_history_response(_stub_task(with_project=True)) resp = task_to_post_history_response(_stub_task(with_project=True))
assert resp.project_slug == "acme-robotics" assert resp.project_slug == "acme-robotics"
assert resp.project_name == "Acme Robotics" assert resp.project_name == "Acme Robotics"
@@ -74,6 +74,6 @@ def test_to_history_response_omits_project_fields_when_project_unset() -> None:
"roboco.api.schemas.project_fields.sa_inspect", "roboco.api.schemas.project_fields.sa_inspect",
return_value=_loaded_inspector(), return_value=_loaded_inspector(),
): ):
resp = _to_history_response(_stub_task(with_project=False)) resp = task_to_post_history_response(_stub_task(with_project=False))
assert resp.project_slug is None assert resp.project_slug is None
assert resp.project_name is None assert resp.project_name is None
@@ -1,7 +1,7 @@
"""Dashboard auditor flag/report mutating routes (``create_auditor_flag``, """Dashboard auditor flag/report mutating routes (``create_auditor_flag``,
``resolve_auditor_flag``, ``create_auditor_report``, ``send_auditor_report``) ``resolve_auditor_flag``, ``create_auditor_report``, ``send_auditor_report``)
are gated to AUDITOR or CEO via a ``CurrentAgentContext`` dependency plus a are gated to AUDITOR or CEO via a ``CurrentAgentContext`` dependency plus
coarse role gate, mirroring ``roboco/api/routes/playbooks.py::_require_curator``. ``roboco.api.deps.require_auditor_or_ceo`` the same check playbooks.py uses.
""" """
from __future__ import annotations from __future__ import annotations
+57 -20
View File
@@ -1,4 +1,4 @@
"""Unit tests for the /git/file range computation (roboco.api.routes.git). """Unit tests for the /git/file range computation (roboco.utils.converters).
Pure logic no DB, no git. Covers the line/context windowing, explicit Pure logic no DB, no git. Covers the line/context windowing, explicit
range, whole-file cap, and truncation flag. range, whole-file cap, and truncation flag.
@@ -6,50 +6,79 @@ range, whole-file cap, and truncation flag.
from __future__ import annotations from __future__ import annotations
from roboco.api.routes.git import _FILE_MAX_LINES, _compute_file_range from roboco.api.routes.git import _FILE_MAX_LINES
from roboco.utils.converters import compute_file_range
class TestComputeFileRange: class TestComputeFileRange:
def test_line_centers_context_window(self) -> None: def test_line_centers_context_window(self) -> None:
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=100, line=50, context=10, start=None, end=None total=100,
line=50,
context=10,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (40, 60, True) assert (s, e_, trunc) == (40, 60, True)
def test_line_window_clamps_to_file_start(self) -> None: def test_line_window_clamps_to_file_start(self) -> None:
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=100, line=3, context=10, start=None, end=None total=100,
line=3,
context=10,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (1, 13, True) assert (s, e_, trunc) == (1, 13, True)
def test_line_window_clamps_to_file_end(self) -> None: def test_line_window_clamps_to_file_end(self) -> None:
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=100, line=98, context=10, start=None, end=None total=100,
line=98,
context=10,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (88, 100, False) assert (s, e_, trunc) == (88, 100, False)
def test_explicit_start_end_override_line(self) -> None: def test_explicit_start_end_override_line(self) -> None:
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=100, line=50, context=10, start=5, end=8 total=100,
line=50,
context=10,
explicit_range=(5, 8),
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (5, 8, True) assert (s, e_, trunc) == (5, 8, True)
def test_whole_file_when_no_range_args(self) -> None: def test_whole_file_when_no_range_args(self) -> None:
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=50, line=None, context=10, start=None, end=None total=50,
line=None,
context=10,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (1, 50, False) assert (s, e_, trunc) == (1, 50, False)
def test_whole_file_capped_when_huge(self) -> None: def test_whole_file_capped_when_huge(self) -> None:
total = _FILE_MAX_LINES + 500 total = _FILE_MAX_LINES + 500
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=total, line=None, context=10, start=None, end=None total=total,
line=None,
context=10,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (1, _FILE_MAX_LINES, True) assert (s, e_, trunc) == (1, _FILE_MAX_LINES, True)
def test_empty_file(self) -> None: def test_empty_file(self) -> None:
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=0, line=None, context=10, start=None, end=None total=0,
line=None,
context=10,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (1, 1, False) assert (s, e_, trunc) == (1, 1, False)
@@ -57,14 +86,22 @@ class TestComputeFileRange:
# start=1, end=total-1 is not the exact-whole-file shape, but the # start=1, end=total-1 is not the exact-whole-file shape, but the
# resolved window is still oversized and must be capped. # resolved window is still oversized and must be capped.
total = 50000 total = 50000
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=total, line=None, context=10, start=1, end=total - 1 total=total,
line=None,
context=10,
explicit_range=(1, total - 1),
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (1, _FILE_MAX_LINES, True) assert (s, e_, trunc) == (1, _FILE_MAX_LINES, True)
def test_oversized_line_context_window_is_capped(self) -> None: def test_oversized_line_context_window_is_capped(self) -> None:
total = 10000 total = 10000
s, e_, trunc = _compute_file_range( s, e_, trunc = compute_file_range(
total=total, line=5000, context=3000, start=None, end=None total=total,
line=5000,
context=3000,
explicit_range=None,
max_lines=_FILE_MAX_LINES,
) )
assert (s, e_, trunc) == (2000, 2000 + _FILE_MAX_LINES - 1, True) assert (s, e_, trunc) == (2000, 2000 + _FILE_MAX_LINES - 1, True)
+1
View File
@@ -816,6 +816,7 @@ async def test_rebase_endpoint_pm_gets_200() -> None:
mock_project.slug = "roboco" mock_project.slug = "roboco"
mock_project_svc = MagicMock() mock_project_svc = MagicMock()
mock_project_svc.get_by_slug = AsyncMock(return_value=mock_project) mock_project_svc.get_by_slug = AsyncMock(return_value=mock_project)
mock_project_svc.resolve_slug_or_404 = AsyncMock(return_value="roboco")
# Mock git service → workspace + rebase succeed without conflict # Mock git service → workspace + rebase succeed without conflict
mock_git_svc = MagicMock() mock_git_svc = MagicMock()