mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
co-authored by
Backend Developer 2
Backend Documenter
parent
109b4d4d82
commit
7804e0fafa
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user