[805e525a] Extract repeated _require_ceo/_to_response/_status_value trio from 11 remaining route files (#785)

* [805e525a] refactor(api): extract _require_ceo/_to_response/_status_value from 11 route files

Move the per-file local _require_ceo wrapper, task->Response converters, and
status-value mappers out of board_programs.py, coroner.py, dogfood.py,
github_app.py, mirror.py, periscope.py, pest_control.py, scales.py,
sentinel.py, spackle.py, and telegram.py routes. _require_ceo call sites now
call the shared require_ceo_role directly; pure converters land in each
domain's roboco/api/schemas/*.py module (task_to_<domain>_response,
<domain>_status_value), mirroring the roadmap.py pattern; board_programs.py's
engine-backed _to_response becomes BoardProgramEngine.to_response, mirroring
release_proposal.py's task_to_proposal_response precedent. Placement-only,
no route/schema/behavior changes.

* [805e525a] docs(api): document Batch C route-helper relocation in api-routes-schemas map

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
roboco-app[bot]
2026-08-01 02:11:13 +00:00
committed by GitHub
co-authored by Backend Developer 2 Backend Documenter
parent 6eb2cf67af
commit c317888aec
22 changed files with 333 additions and 320 deletions
+5 -2
View File
@@ -77,7 +77,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) | | GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) |
| GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) | | GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) |
| GET/POST/DELETE | /api/providers/presets, /presets/{id}/apply, /presets/{id} | provider.py | agent context — save/apply/delete a named full routing snapshot (`docs/map/support-services.md`) | | GET/POST/DELETE | /api/providers/presets, /presets/{id}/apply, /presets/{id} | provider.py | agent context — save/apply/delete a named full routing snapshot (`docs/map/support-services.md`) |
| GET/PUT/DELETE | /api/github-app/credentials ; GET /installations, /installations/{id}/repos | github_app.py | agent context — App id + private key CRUD, installation/repo listing for the panel's repo picker | | GET/PUT/DELETE | /api/github-app/credentials ; GET /installations, /installations/{id}/repos | github_app.py | `require_ceo_role` (agent context) — App id + private key CRUD, installation/repo listing for the panel's repo picker |
| POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` | | POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` |
| GET | /api/telegram/today | telegram.py | `require_ceo_role` (agent context) + 30/60s rate limit — Mini App V4's "Today" brief, backed by `TgCockpitService.today()` (one DB round trip, see `docs/map/notification.md`) | | GET | /api/telegram/today | telegram.py | `require_ceo_role` (agent context) + 30/60s rate limit — Mini App V4's "Today" brief, backed by `TgCockpitService.today()` (one DB round trip, see `docs/map/notification.md`) |
| GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login | | GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login |
@@ -103,7 +103,9 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. | | `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. |
| `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; Batch C (task `805e525a`) extended the same direct-call pattern to board_programs.py/coroner.py/dogfood.py/github_app.py/mirror.py/periscope.py/pest_control.py/scales.py/sentinel.py/spackle.py/telegram.py. |
| `task_to_postmortem_response` (coroner.py) / `task_to_dogfood_cycle_response` + `dogfood_status_value` / `task_to_mirror_cycle_response` + `mirror_status_value` / `task_to_market_brief_response` (periscope.py) / `task_to_sentinel_response`-equivalent (sentinel.py) / matching `task_to_<name>_cycle_response` + `<name>_status_value` pairs in pest_control.py/scales.py/spackle.py | fn | api/schemas/{coroner,dogfood,mirror,periscope,pest_control,scales,sentinel,spackle}.py | Per-domain `TaskTable` -> Response DTO converters + status-string mappers, relocated out of the paired route file's local `_to_response`/`_status_value` (Batch C, task `805e525a`), mirroring `task_to_response`. |
| `BoardProgramEngine.to_response` | svc method | services/board_programs.py | board_programs.py's engine-backed `_to_response` (needs live DB reads via the engine, so it isn't a pure schema converter) relocated here instead of `api/schemas/board_programs.py` — mirrors the `release_proposal.py` precedent for an impure converter (Batch C, task `805e525a`). |
| `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_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. | | `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. |
@@ -261,6 +263,7 @@ roboco/api/
> - ("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. > - (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.
> - (task `805e525a`, "Batch C: extract route helpers from the 11 Board-Program route files Batch A/B never touched", PR #785) placement-only refactor, no route/schema/behavior change: relocated the remaining 26 helper-kind top-level defs (pr_gate finding `276ae32f`) out of `board_programs.py`, `coroner.py`, `dogfood.py`, `github_app.py`, `mirror.py`, `periscope.py`, `pest_control.py`, `scales.py`, `sentinel.py`, `spackle.py`, `telegram.py` — each file's local `_require_ceo(agent)` wrapper is gone, every call site now calls `require_ceo_role` directly (same `action=` strings preserved); each file's `_status_value`/`_to_response` pair moved into its matching `roboco/api/schemas/*.py` module as `<name>_status_value`/`task_to_<name>_response`, mirroring `task_to_response`, except `board_programs.py`'s engine-backed `_to_response` (needs live DB reads) which became `BoardProgramEngine.to_response` in `roboco/services/board_programs.py`, mirroring the `release_proposal.py` precedent for an impure converter. `telegram.py`'s `mount_telegram_miniapp_auth` (app-setup, not request-handling) was left in place, unflagged by the conventions checker. 9 of the 11 route files (`board_programs.py`, `coroner.py`, `dogfood.py`, `mirror.py`, `periscope.py`, `pest_control.py`, `scales.py`, `sentinel.py`, `spackle.py` — the Board Program registry route surface, see CLAUDE.md's "Board Program registry" section) aren't yet itemized in this doc's Files/Key-Endpoints tables above; only `github_app.py`/`telegram.py` were already present. That backfill is still owed and out of scope for this placement-only pass.
## Regression Risks ## Regression Risks
+6 -48
View File
@@ -11,66 +11,24 @@ strategy-engine idle trigger uses.
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.board_programs import BoardProgramResponse
from roboco.foundation.policy.board_programs import PROGRAMS from roboco.foundation.policy.board_programs import PROGRAMS
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.board_programs import BoardProgramEngine, get_board_program_engine from roboco.services.board_programs import get_board_program_engine
router = APIRouter() router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or act on Board Programs")
class BoardProgramResponse(BaseModel):
"""One registry entry's live status — the panel card + edit-project
dialog's opt-in controls both read this shape."""
key: str
title: str
description: str
role: str
trigger: str
scope: str
enabled: bool
opted_in_project_slugs: list[str]
last_opened_at: str | None
open_cycle: bool
last_cycle_summary: str | None
async def _to_response(engine: BoardProgramEngine, key: str) -> BoardProgramResponse:
program = PROGRAMS[key]
enabled = await engine.enabled(key)
open_cycle, last_opened_at = await engine.cycle_state(key)
summary = await engine.prior_cycle_context(key, limit=1)
opted_in = await engine.opted_in_projects(program)
return BoardProgramResponse(
key=key,
title=program.title or key,
description=program.description,
role=program.role,
trigger=program.trigger.value,
scope=program.scope,
enabled=enabled,
opted_in_project_slugs=[p.slug for p in opted_in],
last_opened_at=last_opened_at.isoformat() if last_opened_at else None,
open_cycle=open_cycle,
last_cycle_summary=summary or None,
)
@router.get("", response_model=list[BoardProgramResponse]) @router.get("", response_model=list[BoardProgramResponse])
async def list_board_programs( async def list_board_programs(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> list[BoardProgramResponse]: ) -> list[BoardProgramResponse]:
"""Every registered Board Program's live status.""" """Every registered Board Program's live status."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on Board Programs")
engine = get_board_program_engine(db) engine = get_board_program_engine(db)
return [await _to_response(engine, key) for key in PROGRAMS] return [await engine.to_response(key) for key in PROGRAMS]
@router.post("/{key}/run-now", response_model=BoardProgramResponse) @router.post("/{key}/run-now", response_model=BoardProgramResponse)
@@ -86,7 +44,7 @@ async def run_program_now(
``open_program_cycle`` collapses all three into the same None result, ``open_program_cycle`` collapses all three into the same None result,
and the caller has no actionable distinction between them beyond retry. and the caller has no actionable distinction between them beyond retry.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on Board Programs")
if key not in PROGRAMS: if key not in PROGRAMS:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Unknown Board Program" status_code=status.HTTP_404_NOT_FOUND, detail="Unknown Board Program"
@@ -103,4 +61,4 @@ async def run_program_now(
) )
# Write route commits explicitly (get_db auto-commit is unreliable). # Write route commits explicitly (get_db auto-commit is unreliable).
await db.commit() await db.commit()
return await _to_response(engine, key) return await engine.to_response(key)
+6 -46
View File
@@ -12,7 +12,6 @@ alone. CEO-only, mirroring every other Board Program surface.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -22,62 +21,23 @@ from roboco.api.schemas.coroner import (
PostmortemResponse, PostmortemResponse,
ProcessChangeActionResponse, ProcessChangeActionResponse,
ProcessChangeRejectRequest, ProcessChangeRejectRequest,
task_to_postmortem_response,
) )
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.coroner_service import PLAYBOOK_KIND, get_coroner_service from roboco.services.coroner_service import get_coroner_service
from roboco.services.task import get_task_service from roboco.services.task import get_task_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 Coroner postmortems list")
def _to_response(task: TaskTable) -> PostmortemResponse:
incident = markers.get_coroner_incident(task) or {}
postmortem = markers.get_coroner_postmortem(task) or {}
process_change = postmortem.get("process_change") or {}
return PostmortemResponse(
task_id=str(task.id),
title=task.title,
completed_at=task.updated_at.isoformat() if task.updated_at else None,
incident_task_id=incident.get("incident_task_id"),
incident_kind=incident.get("kind"),
incident_title=incident.get("title"),
incident_summary=postmortem.get("incident_summary"),
root_cause=postmortem.get("root_cause"),
failed_stage=postmortem.get("failed_stage"),
process_change_kind=process_change.get("kind"),
process_change_description=process_change.get("description"),
playbook_id=postmortem.get("playbook_id"),
# A playbook-kind change already drafted into the playbook queue at
# propose time — there is nothing to decide, but the stored status
# stays "proposed", which left the panel rendering approve/dismiss
# buttons that both verbs refuse forever. Derive the terminal status
# the panel's contract expects instead.
process_change_status=(
"not_applicable"
if process_change.get("kind") == PLAYBOOK_KIND
else process_change.get("status", "proposed")
),
process_change_reject_reason=process_change.get("reject_reason"),
process_change_materialized_task_id=process_change.get("materialized_task_id"),
)
@router.get("/postmortems", response_model=list[PostmortemResponse]) @router.get("/postmortems", response_model=list[PostmortemResponse])
async def list_postmortems( async def list_postmortems(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> list[PostmortemResponse]: ) -> list[PostmortemResponse]:
"""Every completed Coroner postmortem, newest first.""" """Every completed Coroner postmortem, newest first."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
tasks = await get_task_service(db).list_completed_coroner_postmortems() tasks = await get_task_service(db).list_completed_coroner_postmortems()
return [_to_response(t) for t in tasks] return [task_to_postmortem_response(t) for t in tasks]
@router.post( @router.post(
@@ -93,7 +53,7 @@ async def approve_process_change(
) -> ProcessChangeActionResponse: ) -> ProcessChangeActionResponse:
"""Materialize the postmortem's process change as a Main-PM-owned root """Materialize the postmortem's process change as a Main-PM-owned root
task (idempotent).""" task (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
result = await get_coroner_service(db).approve_process_change( result = await get_coroner_service(db).approve_process_change(
task_id, created_by=agent.agent_id task_id, created_by=agent.agent_id
) )
@@ -125,7 +85,7 @@ async def reject_process_change(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> ProcessChangeActionResponse: ) -> ProcessChangeActionResponse:
"""Dismiss the postmortem's process change with a reason (idempotent).""" """Dismiss the postmortem's process change with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
result = await get_coroner_service(db).reject_process_change(task_id, data.reason) result = await get_coroner_service(db).reject_process_change(task_id, data.reason)
if result is None: if result is None:
raise HTTPException( raise HTTPException(
+9 -29
View File
@@ -4,7 +4,6 @@ materializes it as a BACKLOG task; nothing here starts it — normal PM
activation takes it from there. Mirrors ``roboco.api.routes.spackle``. activation takes it from there. Mirrors ``roboco.api.routes.spackle``.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -13,39 +12,16 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.dogfood import ( from roboco.api.schemas.dogfood import (
DogfoodCycleResponse, DogfoodCycleResponse,
FrictionFixItemActionResponse, FrictionFixItemActionResponse,
FrictionFixItemResponse,
FrictionFixRejectRequest, FrictionFixRejectRequest,
task_to_dogfood_cycle_response,
) )
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.dogfood_service import get_dogfood_service from roboco.services.dogfood_service import get_dogfood_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 dogfood 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") -> DogfoodCycleResponse:
payload = markers.get_friction_fixes(task) or {}
items = [FrictionFixItemResponse(**item) for item in payload.get("items", [])]
return DogfoodCycleResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
items=items,
)
@router.get("/cycles", response_model=list[DogfoodCycleResponse]) @router.get("/cycles", response_model=list[DogfoodCycleResponse])
async def list_dogfood_cycles( async def list_dogfood_cycles(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -55,9 +31,13 @@ async def list_dogfood_cycles(
A cycle the PO hasn't authored yet (no items drafted) is omitted — there A cycle the PO hasn't authored yet (no items drafted) is omitted — there
is nothing for the CEO to review until ``propose_friction_fixes`` lands. is nothing for the CEO to review until ``propose_friction_fixes`` lands.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the dogfood queue")
tasks = await get_dogfood_service(db).list_open_cycles() tasks = await get_dogfood_service(db).list_open_cycles()
return [_to_response(t) for t in tasks if markers.get_friction_fixes(t)] return [
task_to_dogfood_cycle_response(t)
for t in tasks
if markers.get_friction_fixes(t)
]
@router.post( @router.post(
@@ -73,7 +53,7 @@ async def approve_friction_fix_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> FrictionFixItemActionResponse: ) -> FrictionFixItemActionResponse:
"""Materialize one proposed item as a BACKLOG task (idempotent).""" """Materialize one proposed item as a BACKLOG task (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the dogfood queue")
result = await get_dogfood_service(db).approve_item( result = await get_dogfood_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id task_id, item_id, created_by=agent.agent_id
) )
@@ -107,7 +87,7 @@ async def reject_friction_fix_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> FrictionFixItemActionResponse: ) -> FrictionFixItemActionResponse:
"""Reject one proposed item with a reason (idempotent).""" """Reject one proposed item with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the dogfood queue")
result = await get_dogfood_service(db).reject_item(task_id, item_id, data.reason) result = await get_dogfood_service(db).reject_item(task_id, item_id, data.reason)
if result is None: if result is None:
raise HTTPException( raise HTTPException(
+5 -9
View File
@@ -34,16 +34,12 @@ from roboco.services.github_app_credentials import (
router = APIRouter() router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="manage the GitHub App integration")
@router.get("/credentials", response_model=GitHubAppCredentialsStatus) @router.get("/credentials", response_model=GitHubAppCredentialsStatus)
async def get_github_app_credentials( async def get_github_app_credentials(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> GitHubAppCredentialsStatus: ) -> GitHubAppCredentialsStatus:
"""Whether the App id + private key are stored. Never the key.""" """Whether the App id + private key are stored. Never the key."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage the GitHub App integration")
has_creds = await get_github_app_credentials_service(db).has_credentials() has_creds = await get_github_app_credentials_service(db).has_credentials()
return GitHubAppCredentialsStatus(has_credentials=has_creds) return GitHubAppCredentialsStatus(has_credentials=has_creds)
@@ -59,7 +55,7 @@ async def set_github_app_credentials(
data: GitHubAppCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext data: GitHubAppCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext
) -> GitHubAppCredentialsStatus: ) -> GitHubAppCredentialsStatus:
"""Set the App id + private key together (PEM paste).""" """Set the App id + private key together (PEM paste)."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage the GitHub App integration")
svc = get_github_app_credentials_service(db) svc = get_github_app_credentials_service(db)
try: try:
has_creds = await svc.set_credentials( has_creds = await svc.set_credentials(
@@ -78,7 +74,7 @@ async def clear_github_app_credentials(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> GitHubAppCredentialsStatus: ) -> GitHubAppCredentialsStatus:
"""Clear the App id + private key.""" """Clear the App id + private key."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage the GitHub App integration")
has_creds = await get_github_app_credentials_service(db).set_credentials( has_creds = await get_github_app_credentials_service(db).set_credentials(
app_id="", private_key="" app_id="", private_key=""
) )
@@ -93,7 +89,7 @@ async def get_installations(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> list[InstallationResponse]: ) -> list[InstallationResponse]:
"""List every installation of the configured App.""" """List every installation of the configured App."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage the GitHub App integration")
try: try:
installations = await list_installations(db) installations = await list_installations(db)
except GitHubAppNotConfiguredError as e: except GitHubAppNotConfiguredError as e:
@@ -117,7 +113,7 @@ async def get_installation_repositories(
installation_id: int, db: DbSession, agent: CurrentAgentContext installation_id: int, db: DbSession, agent: CurrentAgentContext
) -> list[InstallationRepositoryResponse]: ) -> list[InstallationRepositoryResponse]:
"""List every repository the given installation can access.""" """List every repository the given installation can access."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage the GitHub App integration")
try: try:
repos = await list_installation_repositories(db, installation_id) repos = await list_installation_repositories(db, installation_id)
except GitHubAppNotConfiguredError as e: except GitHubAppNotConfiguredError as e:
+9 -29
View File
@@ -4,7 +4,6 @@ materializes it as a BACKLOG docs task; nothing here starts it — normal PM
activation takes it from there. Mirrors ``roboco.api.routes.spackle``. activation takes it from there. Mirrors ``roboco.api.routes.spackle``.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -12,40 +11,17 @@ from fastapi import APIRouter, HTTPException, 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.mirror import ( from roboco.api.schemas.mirror import (
MessagingFixItemActionResponse, MessagingFixItemActionResponse,
MessagingFixItemResponse,
MessagingFixRejectRequest, MessagingFixRejectRequest,
MirrorCycleResponse, MirrorCycleResponse,
task_to_mirror_cycle_response,
) )
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.mirror_service import get_mirror_service from roboco.services.mirror_service import get_mirror_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 mirror 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") -> MirrorCycleResponse:
payload = markers.get_messaging_fixes(task) or {}
items = [MessagingFixItemResponse(**item) for item in payload.get("items", [])]
return MirrorCycleResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
items=items,
)
@router.get("/cycles", response_model=list[MirrorCycleResponse]) @router.get("/cycles", response_model=list[MirrorCycleResponse])
async def list_mirror_cycles( async def list_mirror_cycles(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -55,9 +31,13 @@ async def list_mirror_cycles(
A cycle the HoM hasn't authored yet (no items drafted) is omitted — there A cycle the HoM hasn't authored yet (no items drafted) is omitted — there
is nothing for the CEO to review until ``propose_messaging_fixes`` lands. is nothing for the CEO to review until ``propose_messaging_fixes`` lands.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the mirror queue")
tasks = await get_mirror_service(db).list_open_cycles() tasks = await get_mirror_service(db).list_open_cycles()
return [_to_response(t) for t in tasks if markers.get_messaging_fixes(t)] return [
task_to_mirror_cycle_response(t)
for t in tasks
if markers.get_messaging_fixes(t)
]
@router.post( @router.post(
@@ -73,7 +53,7 @@ async def approve_messaging_fix_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> MessagingFixItemActionResponse: ) -> MessagingFixItemActionResponse:
"""Materialize one proposed item as a BACKLOG docs task (idempotent).""" """Materialize one proposed item as a BACKLOG docs task (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the mirror queue")
result = await get_mirror_service(db).approve_item( result = await get_mirror_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id task_id, item_id, created_by=agent.agent_id
) )
@@ -107,7 +87,7 @@ async def reject_messaging_fix_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> MessagingFixItemActionResponse: ) -> MessagingFixItemActionResponse:
"""Reject one proposed item with a reason (idempotent).""" """Reject one proposed item with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the mirror queue")
result = await get_mirror_service(db).reject_item(task_id, item_id, data.reason) result = await get_mirror_service(db).reject_item(task_id, item_id, data.reason)
if result is None: if result is None:
raise HTTPException( raise HTTPException(
+11 -33
View File
@@ -5,7 +5,6 @@ propose time), but each finding carries its own per-item approve/reject,
mirroring ``roboco.api.routes.roadmap``'s shape. mirroring ``roboco.api.routes.roadmap``'s shape.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -14,43 +13,16 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.periscope import ( from roboco.api.schemas.periscope import (
MarketBriefFindingActionResponse, MarketBriefFindingActionResponse,
MarketBriefFindingRejectRequest, MarketBriefFindingRejectRequest,
MarketBriefFindingResponse,
MarketBriefResponse, MarketBriefResponse,
task_to_market_brief_response,
) )
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.periscope_service import get_periscope_service from roboco.services.periscope_service import get_periscope_service
from roboco.services.task import get_task_service from roboco.services.task import get_task_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 Periscope market-briefs list"
)
def _to_response(task: "TaskTable") -> MarketBriefResponse | None:
payload = markers.get_market_brief(task)
if payload is None:
return None
findings = [MarketBriefFindingResponse(**f) for f in payload.get("findings", [])]
return MarketBriefResponse(
task_id=str(task.id),
title=task.title,
completed_at=task.updated_at.isoformat() if task.updated_at else None,
headline=payload.get("headline", ""),
findings=findings,
threats=payload.get("threats", []),
opportunities=payload.get("opportunities", []),
positioning_note=payload.get("positioning_note", ""),
)
@router.get("/briefs", response_model=list[MarketBriefResponse]) @router.get("/briefs", response_model=list[MarketBriefResponse])
async def list_market_briefs( async def list_market_briefs(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -58,9 +30,11 @@ async def list_market_briefs(
"""Recent filed market briefs, newest-first. A completed Periscope """Recent filed market briefs, newest-first. A completed Periscope
exploration without a marker (shouldn't happen — the verb always sets exploration without a marker (shouldn't happen — the verb always sets
one before completing) is omitted rather than rendered blank.""" one before completing) is omitted rather than rendered blank."""
_require_ceo(agent) require_ceo_role(
agent.role, action="view or act on the Periscope market-briefs list"
)
tasks = await get_task_service(db).list_periscope_briefs() tasks = await get_task_service(db).list_periscope_briefs()
return [r for t in tasks if (r := _to_response(t)) is not None] return [r for t in tasks if (r := task_to_market_brief_response(t)) is not None]
@router.post( @router.post(
@@ -76,7 +50,9 @@ async def approve_market_brief_finding(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> MarketBriefFindingActionResponse: ) -> MarketBriefFindingActionResponse:
"""Materialize one finding as a Main-PM-owned root task (idempotent).""" """Materialize one finding as a Main-PM-owned root task (idempotent)."""
_require_ceo(agent) require_ceo_role(
agent.role, action="view or act on the Periscope market-briefs list"
)
result = await get_periscope_service(db).approve_finding( result = await get_periscope_service(db).approve_finding(
task_id, finding_id, created_by=agent.agent_id task_id, finding_id, created_by=agent.agent_id
) )
@@ -110,7 +86,9 @@ async def reject_market_brief_finding(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> MarketBriefFindingActionResponse: ) -> MarketBriefFindingActionResponse:
"""Dismiss one finding with a reason (idempotent).""" """Dismiss one finding with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(
agent.role, action="view or act on the Periscope market-briefs list"
)
result = await get_periscope_service(db).reject_finding( result = await get_periscope_service(db).reject_finding(
task_id, finding_id, data.reason task_id, finding_id, data.reason
) )
+7 -29
View File
@@ -4,7 +4,6 @@ materializes it as a BACKLOG task; nothing here starts it — normal PM
activation takes it from there. Mirrors ``roboco.api.routes.roadmap``. activation takes it from there. Mirrors ``roboco.api.routes.roadmap``.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -13,39 +12,16 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.pest_control import ( from roboco.api.schemas.pest_control import (
PestHuntCycleResponse, PestHuntCycleResponse,
PestHuntItemActionResponse, PestHuntItemActionResponse,
PestHuntItemResponse,
PestHuntRejectRequest, PestHuntRejectRequest,
task_to_pest_hunt_cycle_response,
) )
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.pest_control_service import get_pest_control_service from roboco.services.pest_control_service import get_pest_control_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 pest-control 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") -> PestHuntCycleResponse:
payload = markers.get_pest_hunt(task) or {}
items = [PestHuntItemResponse(**item) for item in payload.get("items", [])]
return PestHuntCycleResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
items=items,
)
@router.get("/cycles", response_model=list[PestHuntCycleResponse]) @router.get("/cycles", response_model=list[PestHuntCycleResponse])
async def list_pest_control_cycles( async def list_pest_control_cycles(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -55,9 +31,11 @@ async def list_pest_control_cycles(
A cycle the PO hasn't authored yet (no items drafted) is omitted — there A cycle the PO hasn't authored yet (no items drafted) is omitted — there
is nothing for the CEO to review until ``propose_bug_hunt`` lands. is nothing for the CEO to review until ``propose_bug_hunt`` lands.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the pest-control queue")
tasks = await get_pest_control_service(db).list_open_cycles() tasks = await get_pest_control_service(db).list_open_cycles()
return [_to_response(t) for t in tasks if markers.get_pest_hunt(t)] return [
task_to_pest_hunt_cycle_response(t) for t in tasks if markers.get_pest_hunt(t)
]
@router.post( @router.post(
@@ -73,7 +51,7 @@ async def approve_pest_hunt_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> PestHuntItemActionResponse: ) -> PestHuntItemActionResponse:
"""Materialize one proposed item as a BACKLOG task (idempotent).""" """Materialize one proposed item as a BACKLOG task (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the pest-control queue")
result = await get_pest_control_service(db).approve_item( result = await get_pest_control_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id task_id, item_id, created_by=agent.agent_id
) )
@@ -107,7 +85,7 @@ async def reject_pest_hunt_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> PestHuntItemActionResponse: ) -> PestHuntItemActionResponse:
"""Reject one proposed item with a reason (idempotent).""" """Reject one proposed item with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the pest-control queue")
result = await get_pest_control_service(db).reject_item( result = await get_pest_control_service(db).reject_item(
task_id, item_id, data.reason task_id, item_id, data.reason
) )
+9 -29
View File
@@ -5,7 +5,6 @@ cancel it) — nothing here creates a task. Mirrors
``roboco.api.routes.pest_control``. ``roboco.api.routes.pest_control``.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -14,39 +13,16 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.scales import ( from roboco.api.schemas.scales import (
RebalanceCycleResponse, RebalanceCycleResponse,
RebalanceItemActionResponse, RebalanceItemActionResponse,
RebalanceItemResponse,
RebalanceRejectRequest, RebalanceRejectRequest,
task_to_rebalance_cycle_response,
) )
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.scales_service import get_scales_service from roboco.services.scales_service import get_scales_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 Scales 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") -> RebalanceCycleResponse:
payload = markers.get_rebalance_plan(task) or {}
items = [RebalanceItemResponse(**item) for item in payload.get("items", [])]
return RebalanceCycleResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
items=items,
)
@router.get("/cycles", response_model=list[RebalanceCycleResponse]) @router.get("/cycles", response_model=list[RebalanceCycleResponse])
async def list_scales_cycles( async def list_scales_cycles(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -56,9 +32,13 @@ async def list_scales_cycles(
A cycle the PO hasn't authored yet (no items drafted) is omitted — there A cycle the PO hasn't authored yet (no items drafted) is omitted — there
is nothing for the CEO to review until ``propose_rebalance`` lands. is nothing for the CEO to review until ``propose_rebalance`` lands.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the Scales queue")
tasks = await get_scales_service(db).list_open_cycles() tasks = await get_scales_service(db).list_open_cycles()
return [_to_response(t) for t in tasks if markers.get_rebalance_plan(t)] return [
task_to_rebalance_cycle_response(t)
for t in tasks
if markers.get_rebalance_plan(t)
]
@router.post( @router.post(
@@ -74,7 +54,7 @@ async def approve_rebalance_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> RebalanceItemActionResponse: ) -> RebalanceItemActionResponse:
"""Execute one proposed item against its live target task (idempotent).""" """Execute one proposed item against its live target task (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the Scales queue")
result = await get_scales_service(db).approve_item( result = await get_scales_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id task_id, item_id, created_by=agent.agent_id
) )
@@ -108,7 +88,7 @@ async def reject_rebalance_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> RebalanceItemActionResponse: ) -> RebalanceItemActionResponse:
"""Reject one proposed item with a reason (idempotent).""" """Reject one proposed item with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the Scales queue")
result = await get_scales_service(db).reject_item(task_id, item_id, data.reason) result = await get_scales_service(db).reject_item(task_id, item_id, data.reason)
if result is None: if result is None:
raise HTTPException( raise HTTPException(
+11 -31
View File
@@ -5,7 +5,6 @@ propose time), but each item carries its own per-item approve/reject,
mirroring ``roboco.api.routes.periscope``'s shape. mirroring ``roboco.api.routes.periscope``'s shape.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -14,41 +13,16 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.sentinel import ( from roboco.api.schemas.sentinel import (
QualityReportItemActionResponse, QualityReportItemActionResponse,
QualityReportItemRejectRequest, QualityReportItemRejectRequest,
QualityReportItemResponse,
QualityReportResponse, QualityReportResponse,
task_to_quality_report_response,
) )
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.sentinel_service import get_sentinel_service from roboco.services.sentinel_service import get_sentinel_service
from roboco.services.task import get_task_service from roboco.services.task import get_task_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 Sentinel quality-reports list"
)
def _to_response(task: "TaskTable") -> QualityReportResponse | None:
payload = markers.get_quality_report(task)
if payload is None:
return None
items = [QualityReportItemResponse(**i) for i in payload.get("items", [])]
return QualityReportResponse(
task_id=str(task.id),
title=task.title,
completed_at=task.updated_at.isoformat() if task.updated_at else None,
headline=payload.get("headline", ""),
items=items,
overall_assessment=payload.get("overall_assessment", ""),
)
@router.get("/reports", response_model=list[QualityReportResponse]) @router.get("/reports", response_model=list[QualityReportResponse])
async def list_quality_reports( async def list_quality_reports(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -56,9 +30,11 @@ async def list_quality_reports(
"""Recent filed quality reports, newest-first. A completed Sentinel """Recent filed quality reports, newest-first. A completed Sentinel
exploration without a marker (shouldn't happen — the verb always sets exploration without a marker (shouldn't happen — the verb always sets
one before completing) is omitted rather than rendered blank.""" one before completing) is omitted rather than rendered blank."""
_require_ceo(agent) require_ceo_role(
agent.role, action="view or act on the Sentinel quality-reports list"
)
tasks = await get_task_service(db).list_sentinel_reports() tasks = await get_task_service(db).list_sentinel_reports()
return [r for t in tasks if (r := _to_response(t)) is not None] return [r for t in tasks if (r := task_to_quality_report_response(t)) is not None]
@router.post( @router.post(
@@ -74,7 +50,9 @@ async def approve_quality_report_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> QualityReportItemActionResponse: ) -> QualityReportItemActionResponse:
"""Materialize one drift item as a Main-PM-owned root task (idempotent).""" """Materialize one drift item as a Main-PM-owned root task (idempotent)."""
_require_ceo(agent) require_ceo_role(
agent.role, action="view or act on the Sentinel quality-reports list"
)
result = await get_sentinel_service(db).approve_item( result = await get_sentinel_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id task_id, item_id, created_by=agent.agent_id
) )
@@ -108,7 +86,9 @@ async def reject_quality_report_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> QualityReportItemActionResponse: ) -> QualityReportItemActionResponse:
"""Dismiss one drift item with a reason (idempotent).""" """Dismiss one drift item with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(
agent.role, action="view or act on the Sentinel quality-reports list"
)
result = await get_sentinel_service(db).reject_item(task_id, item_id, data.reason) result = await get_sentinel_service(db).reject_item(task_id, item_id, data.reason)
if result is None: if result is None:
raise HTTPException( raise HTTPException(
+5 -29
View File
@@ -4,7 +4,6 @@ materializes it as a BACKLOG task; nothing here starts it — normal PM
activation takes it from there. Mirrors ``roboco.api.routes.pest_control``. activation takes it from there. Mirrors ``roboco.api.routes.pest_control``.
""" """
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -12,40 +11,17 @@ from fastapi import APIRouter, HTTPException, 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.spackle import ( from roboco.api.schemas.spackle import (
GapFillItemActionResponse, GapFillItemActionResponse,
GapFillItemResponse,
GapFillRejectRequest, GapFillRejectRequest,
SpackleCycleResponse, SpackleCycleResponse,
task_to_spackle_cycle_response,
) )
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services.spackle_service import get_spackle_service from roboco.services.spackle_service import get_spackle_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 spackle 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") -> SpackleCycleResponse:
payload = markers.get_gap_fill(task) or {}
items = [GapFillItemResponse(**item) for item in payload.get("items", [])]
return SpackleCycleResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
items=items,
)
@router.get("/cycles", response_model=list[SpackleCycleResponse]) @router.get("/cycles", response_model=list[SpackleCycleResponse])
async def list_spackle_cycles( async def list_spackle_cycles(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
@@ -55,9 +31,9 @@ async def list_spackle_cycles(
A cycle the PO hasn't authored yet (no items drafted) is omitted — there A cycle the PO hasn't authored yet (no items drafted) is omitted — there
is nothing for the CEO to review until ``propose_gap_fill`` lands. is nothing for the CEO to review until ``propose_gap_fill`` lands.
""" """
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the spackle queue")
tasks = await get_spackle_service(db).list_open_cycles() tasks = await get_spackle_service(db).list_open_cycles()
return [_to_response(t) for t in tasks if markers.get_gap_fill(t)] return [task_to_spackle_cycle_response(t) for t in tasks if markers.get_gap_fill(t)]
@router.post( @router.post(
@@ -73,7 +49,7 @@ async def approve_gap_fill_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GapFillItemActionResponse: ) -> GapFillItemActionResponse:
"""Materialize one proposed item as a BACKLOG task (idempotent).""" """Materialize one proposed item as a BACKLOG task (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the spackle queue")
result = await get_spackle_service(db).approve_item( result = await get_spackle_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id task_id, item_id, created_by=agent.agent_id
) )
@@ -107,7 +83,7 @@ async def reject_gap_fill_item(
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> GapFillItemActionResponse: ) -> GapFillItemActionResponse:
"""Reject one proposed item with a reason (idempotent).""" """Reject one proposed item with a reason (idempotent)."""
_require_ceo(agent) require_ceo_role(agent.role, action="view or act on the spackle queue")
result = await get_spackle_service(db).reject_item(task_id, item_id, data.reason) result = await get_spackle_service(db).reject_item(task_id, item_id, data.reason)
if result is None: if result is None:
raise HTTPException( raise HTTPException(
+2 -6
View File
@@ -46,16 +46,12 @@ _logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="manage Telegram credentials")
@router.get("/credentials", response_model=TelegramCredentialsStatus) @router.get("/credentials", response_model=TelegramCredentialsStatus)
async def get_telegram_credentials( async def get_telegram_credentials(
db: DbSession, agent: CurrentAgentContext db: DbSession, agent: CurrentAgentContext
) -> TelegramCredentialsStatus: ) -> TelegramCredentialsStatus:
"""Whether the bot token + chat id are stored. Never the secrets.""" """Whether the bot token + chat id are stored. Never the secrets."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage Telegram credentials")
has_creds = await get_telegram_credentials_service(db).has_credentials() has_creds = await get_telegram_credentials_service(db).has_credentials()
return TelegramCredentialsStatus(has_credentials=has_creds) return TelegramCredentialsStatus(has_credentials=has_creds)
@@ -71,7 +67,7 @@ async def set_telegram_credentials(
data: TelegramCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext data: TelegramCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext
) -> TelegramCredentialsStatus: ) -> TelegramCredentialsStatus:
"""Set (or, passing both empty, clear) the bot token + chat id together.""" """Set (or, passing both empty, clear) the bot token + chat id together."""
_require_ceo(agent) require_ceo_role(agent.role, action="manage Telegram credentials")
svc = get_telegram_credentials_service(db) svc = get_telegram_credentials_service(db)
try: try:
has_creds = await svc.set_credentials( has_creds = await svc.set_credentials(
+23
View File
@@ -0,0 +1,23 @@
"""Schemas for the Board Programs registry API — the CEO's status + off-
schedule "run now" surface, mirroring ``roboco.api.schemas.roadmap``."""
from __future__ import annotations
from pydantic import BaseModel
class BoardProgramResponse(BaseModel):
"""One registry entry's live status — the panel card + edit-project
dialog's opt-in controls both read this shape."""
key: str
title: str
description: str
role: str
trigger: str
scope: str
enabled: bool
opted_in_project_slugs: list[str]
last_opened_at: str | None
open_cycle: bool
last_cycle_summary: str | None
+40
View File
@@ -12,8 +12,16 @@ action routes key on the task id alone.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
from roboco.services.coroner_service import PLAYBOOK_KIND
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class PostmortemResponse(BaseModel): class PostmortemResponse(BaseModel):
"""One completed Coroner postmortem — the incident ref + the Auditor's """One completed Coroner postmortem — the incident ref + the Auditor's
@@ -51,3 +59,35 @@ class ProcessChangeActionResponse(BaseModel):
status: str status: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def task_to_postmortem_response(task: TaskTable) -> PostmortemResponse:
incident = markers.get_coroner_incident(task) or {}
postmortem = markers.get_coroner_postmortem(task) or {}
process_change = postmortem.get("process_change") or {}
return PostmortemResponse(
task_id=str(task.id),
title=task.title,
completed_at=task.updated_at.isoformat() if task.updated_at else None,
incident_task_id=incident.get("incident_task_id"),
incident_kind=incident.get("kind"),
incident_title=incident.get("title"),
incident_summary=postmortem.get("incident_summary"),
root_cause=postmortem.get("root_cause"),
failed_stage=postmortem.get("failed_stage"),
process_change_kind=process_change.get("kind"),
process_change_description=process_change.get("description"),
playbook_id=postmortem.get("playbook_id"),
# A playbook-kind change already drafted into the playbook queue at
# propose time — there is nothing to decide, but the stored status
# stays "proposed", which left the panel rendering approve/dismiss
# buttons that both verbs refuse forever. Derive the terminal status
# the panel's contract expects instead.
process_change_status=(
"not_applicable"
if process_change.get("kind") == PLAYBOOK_KIND
else process_change.get("status", "proposed")
),
process_change_reject_reason=process_change.get("reject_reason"),
process_change_materialized_task_id=process_change.get("materialized_task_id"),
)
+23
View File
@@ -3,8 +3,15 @@ Mirrors ``roboco.api.schemas.spackle`` exactly."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class FrictionFixItemResponse(BaseModel): class FrictionFixItemResponse(BaseModel):
"""One evidence-backed UX-friction item draft within a Dogfood walk.""" """One evidence-backed UX-friction item draft within a Dogfood walk."""
@@ -44,3 +51,19 @@ class FrictionFixItemActionResponse(BaseModel):
item_id: str item_id: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def dogfood_status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_dogfood_cycle_response(task: TaskTable) -> DogfoodCycleResponse:
payload = markers.get_friction_fixes(task) or {}
items = [FrictionFixItemResponse(**item) for item in payload.get("items", [])]
return DogfoodCycleResponse(
task_id=str(task.id),
title=task.title,
status=dogfood_status_value(task),
items=items,
)
+23
View File
@@ -3,8 +3,15 @@ Mirrors ``roboco.api.schemas.spackle`` exactly."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class MessagingFixItemResponse(BaseModel): class MessagingFixItemResponse(BaseModel):
"""One evidence-backed messaging-fix item draft within a Mirror audit.""" """One evidence-backed messaging-fix item draft within a Mirror audit."""
@@ -44,3 +51,19 @@ class MessagingFixItemActionResponse(BaseModel):
item_id: str item_id: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def mirror_status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_mirror_cycle_response(task: TaskTable) -> MirrorCycleResponse:
payload = markers.get_messaging_fixes(task) or {}
items = [MessagingFixItemResponse(**item) for item in payload.get("items", [])]
return MirrorCycleResponse(
task_id=str(task.id),
title=task.title,
status=mirror_status_value(task),
items=items,
)
+24
View File
@@ -9,8 +9,15 @@ that per-finding queue, mirrored on ``roboco.api.schemas.roadmap``."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class MarketBriefFindingResponse(BaseModel): class MarketBriefFindingResponse(BaseModel):
"""One cited finding within a Periscope market brief.""" """One cited finding within a Periscope market brief."""
@@ -52,3 +59,20 @@ class MarketBriefFindingActionResponse(BaseModel):
finding_id: str finding_id: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def task_to_market_brief_response(task: TaskTable) -> MarketBriefResponse | None:
payload = markers.get_market_brief(task)
if payload is None:
return None
findings = [MarketBriefFindingResponse(**f) for f in payload.get("findings", [])]
return MarketBriefResponse(
task_id=str(task.id),
title=task.title,
completed_at=task.updated_at.isoformat() if task.updated_at else None,
headline=payload.get("headline", ""),
findings=findings,
threats=payload.get("threats", []),
opportunities=payload.get("opportunities", []),
positioning_note=payload.get("positioning_note", ""),
)
+23
View File
@@ -4,8 +4,15 @@ Mirrors ``roboco.api.schemas.roadmap`` — ``rationale`` becomes the required
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class PestHuntItemResponse(BaseModel): class PestHuntItemResponse(BaseModel):
"""One evidence-backed bug item draft within a Pest Control hunt.""" """One evidence-backed bug item draft within a Pest Control hunt."""
@@ -45,3 +52,19 @@ class PestHuntItemActionResponse(BaseModel):
item_id: str item_id: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def pest_control_status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_pest_hunt_cycle_response(task: TaskTable) -> PestHuntCycleResponse:
payload = markers.get_pest_hunt(task) or {}
items = [PestHuntItemResponse(**item) for item in payload.get("items", [])]
return PestHuntCycleResponse(
task_id=str(task.id),
title=task.title,
status=pest_control_status_value(task),
items=items,
)
+23
View File
@@ -6,8 +6,15 @@ to the target task instead."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class RebalanceItemResponse(BaseModel): class RebalanceItemResponse(BaseModel):
"""One re-priority/cancellation item draft within a Scales rebalance plan.""" """One re-priority/cancellation item draft within a Scales rebalance plan."""
@@ -46,3 +53,19 @@ class RebalanceItemActionResponse(BaseModel):
item_id: str item_id: str
executed_detail: str | None = None executed_detail: str | None = None
detail: str detail: str
def scales_status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_rebalance_cycle_response(task: TaskTable) -> RebalanceCycleResponse:
payload = markers.get_rebalance_plan(task) or {}
items = [RebalanceItemResponse(**item) for item in payload.get("items", [])]
return RebalanceCycleResponse(
task_id=str(task.id),
title=task.title,
status=scales_status_value(task),
items=items,
)
+22
View File
@@ -7,8 +7,15 @@ CEO decides on afterward."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class QualityReportItemResponse(BaseModel): class QualityReportItemResponse(BaseModel):
"""One drift item within a Sentinel quality report.""" """One drift item within a Sentinel quality report."""
@@ -49,3 +56,18 @@ class QualityReportItemActionResponse(BaseModel):
item_id: str item_id: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def task_to_quality_report_response(task: TaskTable) -> QualityReportResponse | None:
payload = markers.get_quality_report(task)
if payload is None:
return None
items = [QualityReportItemResponse(**i) for i in payload.get("items", [])]
return QualityReportResponse(
task_id=str(task.id),
title=task.title,
completed_at=task.updated_at.isoformat() if task.updated_at else None,
headline=payload.get("headline", ""),
items=items,
overall_assessment=payload.get("overall_assessment", ""),
)
+23
View File
@@ -3,8 +3,15 @@ Mirrors ``roboco.api.schemas.pest_control`` exactly."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class GapFillItemResponse(BaseModel): class GapFillItemResponse(BaseModel):
"""One evidence-backed gap-fill item draft within a Spackle audit.""" """One evidence-backed gap-fill item draft within a Spackle audit."""
@@ -44,3 +51,19 @@ class GapFillItemActionResponse(BaseModel):
item_id: str item_id: str
materialized_task_id: str | None = None materialized_task_id: str | None = None
detail: str detail: str
def spackle_status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_spackle_cycle_response(task: TaskTable) -> SpackleCycleResponse:
payload = markers.get_gap_fill(task) or {}
items = [GapFillItemResponse(**item) for item in payload.get("items", [])]
return SpackleCycleResponse(
task_id=str(task.id),
title=task.title,
status=spackle_status_value(task),
items=items,
)
+24
View File
@@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import func, select from sqlalchemy import func, select
from roboco.api.schemas.board_programs import BoardProgramResponse
from roboco.config import settings from roboco.config import settings
from roboco.db.tables import BoardProgramCycleTable, ProjectTable, TaskTable from roboco.db.tables import BoardProgramCycleTable, ProjectTable, TaskTable
from roboco.foundation.policy.board_programs import ( from roboco.foundation.policy.board_programs import (
@@ -318,6 +319,29 @@ class BoardProgramEngine(BaseService):
"""Per-program settings-store override, else the legacy flag.""" """Per-program settings-store override, else the legacy flag."""
return await program_armed(self.session, key) return await program_armed(self.session, key)
async def to_response(self, key: str) -> BoardProgramResponse:
"""Render one registry entry's live status for the CEO surface —
the panel card + edit-project dialog's opt-in controls both read
this shape."""
program = PROGRAMS[key]
enabled = await self.enabled(key)
open_cycle, last_opened_at = await self.cycle_state(key)
summary = await self.prior_cycle_context(key, limit=1)
opted_in = await self.opted_in_projects(program)
return BoardProgramResponse(
key=key,
title=program.title or key,
description=program.description,
role=program.role,
trigger=program.trigger.value,
scope=program.scope,
enabled=enabled,
opted_in_project_slugs=[p.slug for p in opted_in],
last_opened_at=last_opened_at.isoformat() if last_opened_at else None,
open_cycle=open_cycle,
last_cycle_summary=summary or None,
)
async def run_due_programs(self) -> list[str]: async def run_due_programs(self) -> list[str]:
"""Originate a cycle for every enabled, due CRON program, PLUS every """Originate a cycle for every enabled, due CRON program, PLUS every
program whose metric predicate (``_METRIC_PREDICATES``) fires this program whose metric predicate (``_METRIC_PREDICATES``) fires this