[4baffaa3] Batch A: extract route helpers (tasks/a2a/orchestrator/video/journals/role_dep/roadmap/prompter_live) (#738)

* [4baffaa3] refactor(api): relocate route-layer helpers out of batch-A files into services/schemas/deps

Move every non-@router-decorated top-level function out of
roboco/api/routes/{tasks,a2a,orchestrator,video,v1/_role_dep,roadmap,prompter_live}.py
(journals.py had none) into the module that owns its kind of concern:

- DB/side-effecting logic -> the paired roboco/services module
  (task.py, a2a.py, video_engine.py, video_post_service.py, prompter.py)
- DTO-conversion helpers -> roboco/api/schemas/{tasks,video,roadmap}.py,
  matching tasks.py's existing task_to_response pattern
- small HTTP-layer auth guards -> roboco/api/deps.py, matching its
  existing require_ceo_role/require_pm_or_above pattern

Redundant per-file _require_ceo(agent) wrappers (a2a/orchestrator/video/
roadmap) that just partial-applied an already-existing deps.py function
were inlined to direct require_ceo_role(...) calls instead of duplicated
across services. v1/_role_dep.py keeps its per-role frozenset variable
bindings since those are assignments, not function definitions, and
aren't flagged by the architectural-conventions classifier.

Route paths, schemas, and observable behavior are unchanged. Updated 5
existing test files whose imports or monkeypatch targets pointed at the
old private route-module names.

* [4baffaa3] test(conventions): pin batch-A route files already free of helper findings

* [4baffaa3] fix(api): restore fail-closed _auth_required() fallback (GHSA-4f7g-w95g-5q2c)

The batch-A route-helper relocation accidentally narrowed
_auth_required() to a truthy-only check, dropping the unset-value
fallback to settings.environment == "production". An unconfigured
production deploy would then always return False, silently accepting
unauthenticated X-Agent-Role: ceo header spoofing. Restore the
three-branch logic (explicit true/false honored, unset falls back to
the production check) and the GHSA docstring paragraph explaining it.

* [4baffaa3] fix(services): restore missing Board-Program/X-engine source-tag constants in task.py

The batch-A route-helper relocation's task.py edits had dropped ~24
module-level source-tag constants (BARFLY_SOURCE, CORONER_SOURCE,
DOGFOOD_SOURCE, LIBRARIAN_SOURCE, MEGAPHONE_SOURCE, MIRROR_SOURCE,
PERISCOPE_SOURCE, PEST_CONTROL_SOURCE, SCALES_SOURCE, SENTINEL_SOURCE,
SPACKLE_SOURCE, WAR_ROOM_SOURCE, their *_ITEM_SOURCE materialized-task
counterparts, ENV_SYNC_SOURCE, EVAL_BENCH_SOURCE, and the later X-engine
held-draft tags X_EDITORIAL_SOURCE/X_CAMPAIGN_SOURCE/X_BARFLY_SOURCE)
that ~20 downstream service/engine modules and orchestrator.py's
dispatch table import, breaking the whole FastAPI app's import chain
(deps.py -> AgentOrchestrator -> orchestrator.py -> task.py) and
failing collection on 7 test files.

Restored every missing constant in the same style/location as the
existing block, values cross-checked against board_programs.py's
PROGRAMS registry and hardcoded-string test assertions. Folded the
three new X-engine tags into X_SOURCES (x_post_service.py's
task.source not in X_SOURCES membership check gates their
approve/reject).

Also closes a pre-existing PLR0917 (too-many-positional-args) gap in
pyproject.toml's per-file-ignores for roboco/api/routes/*.py,
roboco/api/deps.py, and roboco/services/prompter.py: these files
already carry an established PLR0913 ignore with a documented
FastAPI-DI-contract / MegaTask-contract rationale that applies equally
to PLR0917, which ruff was flagging on the same pre-existing
signatures (get_current_agent_id, get_current_agent_slug,
_cloud_auth_agent_context, get_agent_context, list_tasks_summary,
_rewrite_batch_children).

* [4baffaa3] fix(api): restore verb-rejection logging and fix stale monkeypatch target in orchestrator auth tests

Two regressions surfaced by re-running the full unit test suite after
restoring task.py's import chain (previously masked because the whole
app failed to import):

1. envelope_to_response() (relocated into roboco/api/deps.py from
   v1/_role_dep.py during the batch-A helper extraction) dropped the
   "verb rejected" structlog event an error envelope must leave — a
   rejected envelope rides a 200, so without this the access log can't
   distinguish a verb an agent couldn't satisfy from one that worked
   (four Board Programs died that way on 2026-07-25 with no
   recoverable reason, per tests/unit/api/routes/v1/
   test_verb_rejection_logging.py's docstring). Restored the log call:
   verb name from the request path, error/detail/remediate from the
   envelope, agent_id/agent_role from the request headers.

2. tests/unit/api/test_orchestrator_auth.py's two cloud-auth session
   tests monkeypatched "roboco.api.routes.orchestrator.
   resolve_session_user", the pre-relocation location. The guard that
   actually calls resolve_session_user (require_orchestrator_ceo) now
   lives in roboco/api/deps.py, same as the other route auth test
   files' already-updated pattern (test_deps.py); repointed both
   patches there.

Verified via a full tests/unit/api/ + tests/unit/conventions/
test_route_helper_placement_batch_a.py run: 605 passed, 18 skipped
(Postgres-gated), 1 pre-existing failure unrelated to this diff
(test_cloud_auth.py's oauth2-form test needs a live production DB
connection, not available in this sandboxed workspace).

* [4baffaa3] docs(api-routes-schemas): reflect batch-A route-helper relocation into services/schemas/deps

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
roboco-app[bot]
2026-07-30 10:30:13 +00:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter
parent 666f261a1a
commit 109b4d4d82
25 changed files with 1471 additions and 3834 deletions
+9 -6
View File
@@ -41,7 +41,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| roboco/api/routes/roadmap.py | Board roadmap engine — CEO-only: list open cycles + per-item approve/reject. |
| roboco/api/routes/telegram.py | Telegram credentials CRUD (CEO-only, write-only) + `webapp_auth_router` — a separate public, pre-auth `POST /webapp-auth` mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` are both armed (`mount_telegram_miniapp_auth`); validates a Mini App's `initData` and mints the cloud-auth session cookie; adds its own unconditional `LoginRateLimiter`. |
| roboco/api/auth/ | Cloud auth (FastAPI Users, default off): `backend.py` (cookie transport + password-fingerprint-bound JWT strategy), `manager.py` (`UserManager` + DI chain), `session.py` (`resolve_session_user`, shared by the HTTP dual-path and the WS panel-token gate), `seed.py` (idempotent single seeded CEO login upsert), `routes.py` (always-public `/auth/status` + conditional login/logout mount), `login_limit.py` (`LoginRateLimiter` — per-IP POST rate limit, path-keyed via a `paths: tuple[str, ...]` set so `/login` and `telegram.py`'s `/webapp-auth` get independent buckets). |
| roboco/api/routes/v1/_role_dep.py | Per-role HMAC guards + `envelope_to_response` helper. |
| roboco/api/routes/v1/_role_dep.py | Per-role `Depends` bindings only (`require_dev`/`require_qa`/...) — the actual guard functions (`require_roles`, `require_authenticated_agent`, `envelope_to_response`) live in `roboco/api/deps.py` and are re-exported here (batch-A route-helper relocation, task `4baffaa3`). |
| roboco/api/routes/v1/do.py | Content verbs `/api/v1/do/*` (commit/note/say/dm/evidence/playbook...). |
| roboco/api/routes/v1/flow_dev.py | Developer flow verbs. |
| roboco/api/routes/v1/flow_qa.py | QA flow verbs (claim/pass/fail_review). |
@@ -95,14 +95,16 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| Name | Kind | File:Line | Responsibility |
|------|------|-----------|----------------|
| `require_any_authenticated_agent` | dep | v1/_role_dep.py | HMAC-verify X-Agent-ID/role/team token; router-level guard on do + a2a. |
| `require_<role>` (require_dev/qa/...) | dep | v1/_role_dep.py | Per-role guard: HMAC + role assertion, applied as router dependency. |
| `envelope_to_response` | fn | v1/_role_dep.py | Convert Choreographer `Envelope` to JSON, set status from `envelope.status`. |
| `require_any_authenticated_agent` | dep | v1/_role_dep.py (binds `roboco.api.deps.require_authenticated_agent()`) | HMAC-verify X-Agent-ID/role/team token; router-level guard on do + a2a. |
| `require_<role>` (require_dev/qa/...) | dep | v1/_role_dep.py (binds `roboco.api.deps.require_roles(...)`) | Per-role guard: HMAC + role assertion, applied as router dependency. |
| `envelope_to_response` | fn | api/deps.py (re-exported by v1/_role_dep.py) | Convert Choreographer `Envelope` to JSON, set status from `envelope.status`, and log a structlog "verb rejected" event on an error envelope (`verb`/`error`/`detail`/`remediate`/agent headers) — relocated from `v1/_role_dep.py` in the batch-A route-helper extraction (task `4baffaa3`); a round-1 regression dropped this log call, restored in round-3. |
| `_check_agent_auth_token` | fn | api/deps.py:217 | Core HMAC verify; rejects invalid tokens even in dev; required-only in prod. |
| `require_panel_token` | dep | api/deps.py:251 | CEO-signed HMAC gate for live-chat bridges (HTTP analog of WS gate). |
| `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. |
| `_require_ceo` | dep | routes/orchestrator.py:37 | Router-level CEO-HMAC guard on orchestrator control routes. |
| `_validated_agent_id` | fn | routes/orchestrator.py:99 | 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. |
| `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`. |
| `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. |
| `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). |
| `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. |
| `_scrub_secrets` | fn | api/middleware.py:389 | Deep-redact known secret fields from logged 422 bodies. |
@@ -255,6 +257,7 @@ roboco/api/
> - `baa87d58` (2026-07-19, PR #576, Telegram Mini App V4) adds `GET /api/telegram/today` (`require_ceo_role` + 30/60s rate limit) backed by new `TgCockpitService` + the `TelegramTodayResponse`/`TodayNeedsYou`/`TodayFleet`/`TodaySpend`/`TodayVelocity`/`TodayShip` schema family in `api/schemas/telegram.py` — see `docs/map/notification.md` for the service, `docs/map/panel.md` for the cockpit's Today tab.
> - `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`.
> - (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.
## Regression Risks
+13 -6
View File
@@ -152,8 +152,10 @@ select = [
"roboco/services/*.py" = ["PLC0415"]
# confirm_live_batch carries the MegaTask confirm contract (title, drafts,
# agent_id, project_ids, route, session_id) — same >5-kwarg rationale as the
# gateway verb surfaces below.
"roboco/services/prompter.py" = ["PLR0913"]
# gateway verb surfaces below. PLR0917: _rewrite_batch_children's positional
# args are the same MegaTask redraft contract (umbrella/drafts/children/
# wave_of/agent_id/agent_role).
"roboco/services/prompter.py" = ["PLR0913", "PLR0917"]
# send_dependency_revival_notification carries the coordination-event contract
# (task_id, assignee, completed_dependency_id, from_agent, to_ceo, db_session) —
# db_session is the caller's session for event-loop-safe notification creation.
@@ -185,13 +187,18 @@ select = [
# FastAPI resolves path/query param annotations at runtime via
# get_type_hints(), even under `from __future__ import annotations` — a
# stdlib type used only in a path param (e.g. `task_id: UUID`) can't be
# deferred into TYPE_CHECKING without breaking route registration.
"roboco/api/routes/*.py" = ["PLC0415", "PLR0913", "TC003"]
# deferred into TYPE_CHECKING without breaking route registration. PLR0917
# (too-many-positional) is the same >5-arg rule as PLR0913 applied to
# positional-or-keyword params specifically — FastAPI's Depends/Query/Path
# params aren't declared keyword-only, so the same route-contract rationale
# covers it.
"roboco/api/routes/*.py" = ["PLC0415", "PLR0913", "PLR0917", "TC003"]
# deps.py is the DI wiring hub; it defers a few service imports to call time
# to avoid import cycles with the modules it wires (same rationale as above).
# get_agent_context's dual-path helpers carry the same header/cookie contract
# as the routes above (X-Agent-* + the session cookie), hence PLR0913 too.
"roboco/api/deps.py" = ["PLC0415", "PLR0913"]
# as the routes above (X-Agent-* + the session cookie), hence PLR0913/PLR0917
# too.
"roboco/api/deps.py" = ["PLC0415", "PLR0913", "PLR0917"]
"roboco/runtime/*.py" = ["PLC0415"]
# The e2e smoke harness defers every roboco import until the stack fixture
# runs, so the default (skipped) suite never pays the app-surface import
+207 -31
View File
@@ -9,12 +9,12 @@ from __future__ import annotations
import contextlib
import os
import time
from typing import TYPE_CHECKING, Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any, cast
from uuid import UUID
import jwt
import structlog
from fastapi import Cookie, Depends, Header, HTTPException, Response, status
from fastapi import Cookie, Depends, Header, HTTPException, Response, params, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -53,6 +53,10 @@ logger = structlog.get_logger()
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
from fastapi import Request
from roboco.services.gateway.envelope import Envelope
# Type alias for database session dependency. get_db_committed stashes the
# session on request.state so DbCommitMiddleware can commit it before the
# response reaches the client (see roboco/db/base.py, roboco/api/middleware.py).
@@ -136,7 +140,6 @@ OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)]
async def get_current_agent_id(
*,
db: DbSession,
response: Response,
x_agent_id: Annotated[str | None, Header()] = None,
@@ -151,13 +154,13 @@ async def get_current_agent_id(
spoof and is rejected (see _cloud_auth_agent_context)."""
if settings.cloud_auth_enabled:
ctx = await _cloud_auth_agent_context(
db=db,
response=response,
x_agent_id=x_agent_id,
x_agent_role=x_agent_role,
x_agent_team=x_agent_team,
x_agent_token=x_agent_token,
session_cookie=roboco_session,
db,
response,
x_agent_id,
x_agent_role,
x_agent_team,
x_agent_token,
roboco_session,
)
return ctx.agent_id
if not x_agent_id:
@@ -173,7 +176,6 @@ CurrentAgentId = Annotated[UUID, Depends(get_current_agent_id)]
async def get_current_agent_slug(
*,
db: DbSession,
response: Response,
x_agent_id: Annotated[str | None, Header()] = None,
@@ -187,13 +189,13 @@ async def get_current_agent_slug(
slug; a CEO cookie resolves to 'ceo')."""
if settings.cloud_auth_enabled:
ctx = await _cloud_auth_agent_context(
db=db,
response=response,
x_agent_id=x_agent_id,
x_agent_role=x_agent_role,
x_agent_team=x_agent_team,
x_agent_token=x_agent_token,
session_cookie=roboco_session,
db,
response,
x_agent_id,
x_agent_role,
x_agent_team,
x_agent_token,
roboco_session,
)
assert ctx.slug is not None # cloud-auth ctx always carries a slug
return ctx.slug
@@ -234,10 +236,10 @@ OptionalAgentId = Annotated[UUID | None, Depends(get_optional_agent_id)]
def _auth_required() -> bool:
"""True when agent HMAC auth is mandatory (prod-ish) vs opt-in (dev).
An UNSET flag fails closed in production: a public deployment must not sit
in header-trust mode, where any client reaching the API can claim
``X-Agent-Role: ceo`` without a signed token (GHSA-4f7g-w95g-5q2c). An
explicit opt-out still stands for a trusted private-network prod deploy.
GHSA-4f7g-w95g-5q2c: an unset flag must not leave a production deploy in
header-trust mode where any client can claim X-Agent-Role: ceo. An
explicit true/false is always honored; unset falls back to whether the
deploy is in production.
"""
val = os.environ.get("ROBOCO_AGENT_AUTH_REQUIRED", "").strip().lower()
if val in ("1", "true", "yes"):
@@ -485,7 +487,6 @@ def _should_remint(token: str) -> bool:
async def _cloud_auth_agent_context(
*,
db: AsyncSession,
response: Response,
x_agent_id: str | None,
@@ -548,7 +549,6 @@ async def _cloud_auth_agent_context(
async def get_agent_context(
*,
db: DbSession,
response: Response,
x_agent_id: Annotated[str | None, Header()] = None,
@@ -578,13 +578,13 @@ async def get_agent_context(
db, x_agent_id, x_agent_role, x_agent_team, x_agent_token
)
return await _cloud_auth_agent_context(
db=db,
response=response,
x_agent_id=x_agent_id,
x_agent_role=x_agent_role,
x_agent_team=x_agent_team,
x_agent_token=x_agent_token,
session_cookie=roboco_session,
db,
response,
x_agent_id,
x_agent_role,
x_agent_team,
x_agent_token,
roboco_session,
)
@@ -782,6 +782,85 @@ async def get_content_actions(
# =============================================================================
# =============================================================================
# ORCHESTRATOR ROUTE DEPENDENCIES (relocated from api/routes/orchestrator.py)
# =============================================================================
async def require_orchestrator_ceo(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str, Header(alias="X-Agent-Role")],
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
session_cookie: Annotated[str | None, Cookie(alias=SESSION_COOKIE_NAME)] = None,
) -> None:
"""CEO-only guard for the orchestrator control routes (spawn / stop /
resolve-wait / mark-waiting, plus the read-only status views) any
client that could reach the API could previously spawn, stop, or
manipulate any agent's runtime state. Mirrors the panel-token approach
used by the WebSocket streams (DB-free): it binds the presented
``X-Agent-ID`` to a verified HMAC token and asserts the role is CEO. In
dev (header-trust) mode a missing token is a no-op (the panel/operator
flow keeps working), but a presented-but-forged token is still rejected
the same contract as the v1 flow role guards and the do router. CEO is
the sole operator role; agents (developers/QA/PMs) drive the orchestrator
via MCP verbs, not these HTTP routes, so a developer token is correctly
403'd here. The CEO role check itself delegates to ``require_ceo_role``
(#25 — the single source of truth shared with the release routes).
"""
# Bind the role header to a verified token BEFORE trusting it (same
# defense-in-depth contract as the v1 flow role guards in _role_dep.py).
if not settings.cloud_auth_enabled:
_check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token)
require_ceo_role(x_agent_role, action="control the orchestrator")
return
# cloud_auth on: CEO HMAC token OR CEO session cookie (panel path).
if x_agent_token:
if not verify_agent_token(x_agent_token, CEO_AGENT_ID, "ceo", ""):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid X-Agent-Token — signature mismatch.",
)
require_ceo_role(x_agent_role, action="control the orchestrator")
return
async for db in get_db():
user = await resolve_session_user(session_cookie, db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
"Cloud auth is enabled — a valid session "
"or agent token is required."
),
)
return
def validate_agent_id_param(agent_id: str) -> str:
"""Reject an ``agent_id`` that could traverse a filesystem path downstream.
``agent_id`` is an opaque slug / uuid the orchestrator assigns, but it is a
request path parameter and flows into per-agent paths (e.g. the grok usage
dir). Reject every traversal vector empty, ``.`` / ``..``, a ``/`` or
``\\`` separator, or an embedded NUL at the HTTP boundary with 422 before
it reaches any path. Explicit guards (not a regex) so CodeQL models this as a
path-injection barrier; the runtime ``_grok_usage_dir`` repeats the check as
defense in depth for non-HTTP callers.
"""
if (
not agent_id
or agent_id in {".", ".."}
or "/" in agent_id
or "\\" in agent_id
or "\x00" in agent_id
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid agent_id",
)
return agent_id
def get_pagination(
limit: int = 50,
offset: int = 0,
@@ -794,3 +873,100 @@ def get_pagination(
PaginationDep = Annotated[PaginationParams, Depends(get_pagination)]
# =============================================================================
# V1 FLOW ROUTER DEPENDENCIES (relocated from api/routes/v1/_role_dep.py)
# =============================================================================
#
# Every v1 flow router gets one of these as a dependency so the role check
# happens before the choreographer body even runs. Defense in depth — the
# choreographer also re-checks role internally for verbs that branch on it.
def require_roles(allowed: frozenset[Role]) -> params.Depends:
def _check(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str, Header(alias="X-Agent-Role")],
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
) -> None:
# Bind the role header to a verified token BEFORE trusting it. These v1
# flow guards are the sole gate for the /api/v1/flow/* endpoints, but
# previously checked only the role string — unlike get_agent_context,
# which already verifies the token. So a forged X-Agent-Role passed, and
# in strict mode (ROBOCO_AGENT_AUTH_REQUIRED) the token was never
# required here. In header-trust (dev) mode a missing token stays a
# no-op; any presented token is still verified.
_check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token)
# `Role` is a StrEnum, so the lowercase header string compares equal
# to its matching member.
if x_agent_role.lower() not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{x_agent_role}' not allowed for this endpoint group",
)
return cast("params.Depends", Depends(_check))
def require_authenticated_agent() -> params.Depends:
"""Token-only guard for the content-tool (do) router.
The do router serves every role content tools are role-uniform, with
per-role removal handled in the spawn manifest so, unlike the flow
routers, there is no single role to assert. But it must still bind the
presented ``X-Agent-ID`` to a verified HMAC token when
``ROBOCO_AGENT_AUTH_REQUIRED=true`` and reject a forged token even in
dev mode, exactly as the flow role guards do. Without this the
``/api/v1/do/*`` endpoints were the one agent-gateway path that
accepted a forged ``X-Agent-ID`` with no token check a weaker gate
than ``/api/v1/flow/*``. The role/team headers are optional (the do
MCP server sends role but not team); they only feed the HMAC payload,
so a missing team is the empty-string team the token was issued with.
"""
def _check(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str | None, Header(alias="X-Agent-Role")] = None,
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
) -> None:
_check_agent_auth_token(
x_agent_id, x_agent_role or "", x_agent_team, x_agent_token
)
return cast("params.Depends", Depends(_check))
def envelope_to_response(env: Envelope, request: Request) -> dict[str, Any]:
"""Stamp the request's correlation_id onto the envelope and return wire-dict.
``CorrelationIdMiddleware`` writes the inbound (or freshly-generated)
``X-Correlation-ID`` to ``request.state.correlation_id``. We pull it
here so the agent receives the same id it sent (or can capture the
server-generated one) and ops can join logs across the full
MCP -> API -> service hop.
A rejected envelope rides a 200, so the access log alone can't
distinguish a verb an agent could not satisfy from one that worked
four Board Programs died that way on 2026-07-25 with no recoverable
reason. Every error envelope therefore leaves a "verb rejected" trace
naming the verb (the request path's last segment), the agent, and the
remediation the agent was given; a success envelope logs nothing.
"""
cid = getattr(request.state, "correlation_id", None)
if cid is not None and env.correlation_id is None:
env.correlation_id = cid
if env.error is not None:
logger.info(
"verb rejected",
verb=request.url.path.rsplit("/", 1)[-1],
error=env.error,
detail=env.message,
remediate=env.remediate,
agent_id=request.headers.get("X-Agent-ID"),
agent_role=request.headers.get("X-Agent-Role"),
correlation_id=env.correlation_id,
)
return env.as_dict()
+6 -36
View File
@@ -55,7 +55,6 @@ from roboco.api.schemas.a2a_chat import (
from roboco.db.base import get_session_factory
from roboco.enforcement import A2AAccessDeniedError
from roboco.models.a2a import (
A2AConversation,
A2AConversationStatus,
A2ATask,
AgentCard,
@@ -64,7 +63,7 @@ from roboco.models.a2a import (
SendMessageRequest,
)
from roboco.security import guard_deco, prompt_injection_validator
from roboco.services.a2a import A2AService
from roboco.services.a2a import A2AService, resolve_reply_target
from roboco.utils.converters import require_uuid
# Router for A2A API endpoints (mounted at /api/a2a)
@@ -918,35 +917,6 @@ async def get_task_conversations(
# it, and let the CEO chime into an existing thread as itself.
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or reply to the A2A live view")
def _resolve_reply_target(conv: A2AConversation, to_agent: str) -> None:
"""Validate the CEO's reply target against the pairwise conversation.
Raises the appropriate 400 HTTPException kept out of the route handler
to keep its cyclomatic complexity low. A2A conversations are strictly
pairwise (no N-party thread), so the CEO must address one of the two
real participants; A2A is also scoped to a task by construction
(A2AService.send requires task_id), so an untethered conversation can't
be replied into via this path.
"""
if to_agent not in (conv.agent_a, conv.agent_b):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"{to_agent} is not a participant in this conversation "
f"(participants: {conv.agent_a}, {conv.agent_b})"
),
)
if conv.task_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Conversation has no linked task_id — A2A requires one",
)
@router.get("/chat/admin/conversations")
async def list_admin_conversations(
db: DbSession,
@@ -954,7 +924,7 @@ async def list_admin_conversations(
limit: int = Query(50, ge=1, le=100),
) -> AdminConversationListResponse:
"""CEO-only: list conversations across every agent pair, most-recent-first."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or reply to the A2A live view")
service = A2AService(db)
conversations = await service.list_conversations_admin(limit)
@@ -991,7 +961,7 @@ async def list_admin_pairs(
pair's representative conversation stats when one exists — the pair
cards the panel groups into sections (each cell, the PM chain, board).
"""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or reply to the A2A live view")
service = A2AService(db)
pairs = await service.list_admin_pairs()
@@ -1026,7 +996,7 @@ async def list_admin_chat_messages(
before: datetime | None = None,
) -> MessageListResponse:
"""CEO-only: read any conversation's transcript, participant or not."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or reply to the A2A live view")
service = A2AService(db)
messages = await service.get_messages_admin(
@@ -1084,7 +1054,7 @@ async def reply_as_ceo(
inserted into THIS conversation (readable by both participants) and
addressed to one of its two real participants via ``interject_as_ceo``.
"""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or reply to the A2A live view")
service = A2AService(db)
conv = await service.get_conversation_admin(require_uuid(conversation_id))
@@ -1093,7 +1063,7 @@ async def reply_as_ceo(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conversation not found: {conversation_id}",
)
_resolve_reply_target(conv, data.to_agent)
resolve_reply_target(conv, data.to_agent)
msg = await service.interject_as_ceo(
conversation_id=require_uuid(conversation_id),
+11 -147
View File
@@ -5,21 +5,15 @@ API endpoints for managing the Agent Orchestrator.
"""
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Cookie, Depends, Header, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from guard_core.handlers.behavior_handler import BehaviorRule
from roboco.agents_config import CEO_AGENT_ID, _resolve_to_slug, verify_agent_token
from roboco.api.auth.backend import SESSION_COOKIE_NAME
from roboco.api.auth.session import resolve_session_user
from roboco.api.deps import (
_check_agent_auth_token,
get_db,
get_orchestrator,
require_ceo_role,
require_orchestrator_ceo,
set_orchestrator,
validate_agent_id_param,
)
from roboco.api.schemas.orchestrator import (
AgentStatusResponse,
@@ -29,105 +23,21 @@ from roboco.api.schemas.orchestrator import (
SpawnAgentResponse,
WaitingAgentResponse,
)
from roboco.config import settings
from roboco.db.base import get_db_context
from roboco.db.tables import TaskTable
from roboco.runtime import AgentState
from roboco.runtime.orchestrator import AgentReadinessError
from roboco.security import guard_deco, prompt_injection_validator
from roboco.services.task import get_task_service
from roboco.services.task import resolve_manual_spawn_prompt
_RUNAWAY_RULES = [
BehaviorRule(rule_type="frequency", threshold=120, window=60, action="log")
]
# Orchestrator control routes (spawn / stop / resolve-wait / mark-waiting,
# plus the read-only status views) are operator/CEO control surfaces — any
# client that could reach the API could previously spawn, stop, or
# manipulate any agent's runtime state. The guard mirrors the panel-token
# approach used by the WebSocket streams (DB-free): it binds the presented
# ``X-Agent-ID`` to a verified HMAC token and asserts the role is CEO. In
# dev (header-trust) mode a missing token is a no-op (the panel/operator
# flow keeps working), but a presented-but-forged token is still rejected —
# the same contract as the v1 flow role guards and the do router. CEO is the
# sole operator role; agents (developers/QA/PMs) drive the orchestrator via
# MCP verbs, not these HTTP routes, so a developer token is correctly 403'd
# here. The CEO role check itself delegates to ``require_ceo_role`` (#25 —
# the single source of truth shared with the release routes).
async def _require_ceo(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str, Header(alias="X-Agent-Role")],
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
session_cookie: Annotated[str | None, Cookie(alias=SESSION_COOKIE_NAME)] = None,
) -> None:
# Bind the role header to a verified token BEFORE trusting it (same
# defense-in-depth contract as the v1 flow role guards in _role_dep.py).
if not settings.cloud_auth_enabled:
_check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token)
require_ceo_role(x_agent_role, action="control the orchestrator")
return
# cloud_auth on: CEO HMAC token OR CEO session cookie (panel path).
if x_agent_token:
if not verify_agent_token(x_agent_token, CEO_AGENT_ID, "ceo", ""):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid X-Agent-Token — signature mismatch.",
)
require_ceo_role(x_agent_role, action="control the orchestrator")
return
async for db in get_db():
user = await resolve_session_user(session_cookie, db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
"Cloud auth is enabled — a valid session "
"or agent token is required."
),
)
return
router = APIRouter(dependencies=[Depends(_require_ceo)])
router = APIRouter(dependencies=[Depends(require_orchestrator_ceo)])
# Re-export set_orchestrator for bootstrap code
__all__ = ["router", "set_orchestrator"]
def _validated_agent_id(agent_id: str) -> str:
"""Reject an ``agent_id`` that could traverse a filesystem path downstream,
then normalize it to the canonical slug the runtime addresses containers by.
``agent_id`` is an opaque slug / uuid the orchestrator assigns, but it is a
request path parameter and flows into per-agent paths (e.g. the grok usage
dir). Reject every traversal vector empty, ``.`` / ``..``, a ``/`` or
``\\`` separator, or an embedded NUL at the HTTP boundary with 422 before
it reaches any path. Explicit guards (not a regex) so CodeQL models this as a
path-injection barrier; the runtime ``_grok_usage_dir`` repeats the check as
defense in depth for non-HTTP callers.
A caller (e.g. the panel) may pass an agent's DB UUID instead of its slug —
``_resolve_to_slug`` maps it to the canonical slug so the runtime container
(named ``roboco-agent-{slug}``) and instance registry are addressed
consistently regardless of which identifier form was sent. An unknown UUID
(not in the seed map) passes through unchanged, same as today.
"""
if (
not agent_id
or agent_id in {".", ".."}
or "/" in agent_id
or "\\" in agent_id
or "\x00" in agent_id
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid agent_id",
)
return _resolve_to_slug(agent_id)
# =============================================================================
# Routes
# =============================================================================
@@ -181,7 +91,7 @@ async def get_status() -> OrchestratorStatusResponse:
)
async def get_agent_status(agent_id: str) -> AgentStatusResponse:
"""Get status of a specific agent."""
agent_id = _validated_agent_id(agent_id)
agent_id = validate_agent_id_param(agent_id)
orchestrator = get_orchestrator()
instance = orchestrator.get_instance(agent_id)
@@ -227,52 +137,6 @@ async def get_waiting_agents() -> list[WaitingAgentResponse]:
]
def _build_manual_spawn_prompt(task: TaskTable, ceo_note: str | None) -> str:
"""Build the initial prompt for a CEO-triggered manual (panel) spawn.
Mirrors the tone of dispatcher-built prompts (e.g. ``_build_pr_review_prompt``
in the orchestrator): point the agent at the task by id/title/status and
trust the gateway envelope's ``next`` / ``remediate`` to guide the actual
claim verb, rather than enumerating per-role verbs here.
"""
lines = [
"You were manually spawned by the CEO to work a specific task.",
"",
f"TASK ID: {task.id}",
f"TITLE: {task.title}",
f"STATUS: {task.status.value}",
"",
"Claim it with the claim verb appropriate to your role and this "
"task's current state, then proceed. Trust the gateway envelope's "
"`next` / `remediate` fields to guide you rather than guessing.",
]
if ceo_note:
lines += ["", "== CEO NOTE ==", ceo_note]
return "\n".join(lines)
async def _resolve_manual_spawn_prompt(
task_id: str | None, ceo_message: str | None
) -> str | None:
"""Best-effort task-aware prompt for a manual panel spawn.
Falls back to ``ceo_message`` unchanged (current behavior) on any lookup
failure bad ``task_id``, DB hiccup, task not found. Enrichment must
never block a spawn the CEO already asked for; ``spawn_agent``'s own
readiness gate is the real gatekeeper for an invalid/not-ready task.
"""
if not task_id:
return ceo_message
try:
async with get_db_context() as db:
task = await get_task_service(db).get(UUID(task_id))
except Exception:
return ceo_message
if task is None:
return ceo_message
return _build_manual_spawn_prompt(task, ceo_message)
@router.post(
"/agents/{agent_id}/spawn",
response_model=SpawnAgentResponse,
@@ -292,11 +156,11 @@ async def spawn_agent(
data: SpawnAgentRequest | None = None,
) -> SpawnAgentResponse:
"""Spawn an agent."""
agent_id = _validated_agent_id(agent_id)
agent_id = validate_agent_id_param(agent_id)
orchestrator = get_orchestrator()
task_id = data.task_id if data else None
ceo_message = data.initial_prompt if data else None
prompt = await _resolve_manual_spawn_prompt(task_id, ceo_message)
prompt = await resolve_manual_spawn_prompt(task_id, ceo_message)
# Pre-check for already-running signaling (see return below). Snapshot the
# instance identity BEFORE calling spawn_agent, which silently reuses a
@@ -376,7 +240,7 @@ async def spawn_agent(
@guard_deco.block_clouds()
async def stop_agent(agent_id: str, graceful: bool = True) -> None:
"""Stop an agent."""
agent_id = _validated_agent_id(agent_id)
agent_id = validate_agent_id_param(agent_id)
orchestrator = get_orchestrator()
await orchestrator.stop_agent(
agent_id, graceful=graceful, stop_reason="stop_agent_api"
@@ -397,7 +261,7 @@ async def resolve_wait(
data: ResolveWaitRequest,
) -> AgentStatusResponse:
"""Resolve a wait condition."""
agent_id = _validated_agent_id(agent_id)
agent_id = validate_agent_id_param(agent_id)
orchestrator = get_orchestrator()
instance = await orchestrator.resolve_wait(agent_id, data.resolution)
@@ -432,7 +296,7 @@ async def mark_waiting(
task_id: str | None = None,
) -> None:
"""Mark an agent as waiting long."""
agent_id = _validated_agent_id(agent_id)
agent_id = validate_agent_id_param(agent_id)
orchestrator = get_orchestrator()
await orchestrator.mark_waiting_long(
agent_id=agent_id,
+13 -83
View File
@@ -40,8 +40,13 @@ from roboco.api.schemas.prompter_live import (
StartLiveResponse,
)
from roboco.security import guard_deco, prompt_injection_validator
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import get_prompter_service
from roboco.services.base import ServiceError
from roboco.services.prompter import (
get_prompter_service,
intake_scope_for_task,
start_batch_re_interview,
translate_prompter_error,
)
from roboco.services.prompter_live import get_live_registry
if TYPE_CHECKING:
@@ -50,28 +55,6 @@ if TYPE_CHECKING:
router = APIRouter()
def _translate_service_error(e: ServiceError) -> HTTPException:
"""Service error → HTTP status (mirrors the legacy prompter route)."""
if isinstance(e, NotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": "not_found", "message": e.message},
)
if isinstance(e, ValidationError):
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "validation_error",
"message": e.message,
"field": e.field,
},
)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "internal_error", "message": e.message},
)
@router.post(
"/live/start",
response_model=StartLiveResponse,
@@ -212,7 +195,7 @@ async def confirm_live(
route=body.route,
)
except ServiceError as e:
raise _translate_service_error(e) from e
raise translate_prompter_error(e) from e
await db.commit()
# Board route (first confirm, not a re-draft): keep the intake agent alive
@@ -253,7 +236,7 @@ async def preview_live_batch(
try:
return service.preview_batch(body.drafts)
except ServiceError as e:
raise _translate_service_error(e) from e
raise translate_prompter_error(e) from e
@router.post("/live/{session_id}/confirm-batch", status_code=status.HTTP_201_CREATED)
@@ -303,7 +286,7 @@ async def confirm_live_batch(
session_id=session_id,
)
except ServiceError as e:
raise _translate_service_error(e) from e
raise translate_prompter_error(e) from e
await db.commit()
if (
@@ -317,59 +300,6 @@ async def confirm_live_batch(
return result
async def _intake_scope_for_task(
db: DbSession, task: Any
) -> tuple[str | None, str | None]:
"""Return (project_slug, product_id) intake scope for a task — exactly one."""
if task.product_id is not None:
return None, str(task.product_id)
if task.project_id is not None:
from roboco.services.project import get_project_service
proj = await get_project_service(db).get(UUID(str(task.project_id)))
return (proj.slug if proj else None), None
return None, None
async def _start_batch_re_interview(
db: DbSession, umbrella: Any, entries: list[dict[str, Any]]
) -> StartLiveResponse:
"""Cold re-interview for a MegaTask umbrella.
Recovers the batch's multi-repo scope from its root-subtasks' own project /
cell-map targets (no single project/product lives on the branchless
umbrella) and seeds a batch-aware redraft message. 400 only when nothing is
recoverable (e.g. every root-subtask was itself cancelled).
"""
from roboco.services.prompter import compose_batch_redraft_message
from roboco.services.task import get_task_service
task_service = get_task_service(db)
umbrella_id = UUID(str(umbrella.id))
children = await task_service.get_live_subtasks(umbrella_id)
project_ids = await task_service.distinct_projects_for_batch(umbrella_id)
if not project_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This MegaTask has no recoverable projects to re-interview against.",
)
initial_message = compose_batch_redraft_message(umbrella, children, entries)
session_id = uuid4().hex
try:
await get_orchestrator().start_intake_session(
session_id,
project_ids=[str(pid) for pid in project_ids],
initial_message=initial_message,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start re-interview session: {exc}",
) from exc
return StartLiveResponse(session_id=session_id, project_ids=project_ids)
@router.post(
"/live/re-interview/{task_id}",
response_model=StartLiveResponse,
@@ -388,7 +318,7 @@ async def re_interview(
draft updates this task instead of creating a new one. This is the cold path
(and the resilience fallback for the keep-alive re-draft).
A MegaTask umbrella takes a separate branch (``_start_batch_re_interview``):
A MegaTask umbrella takes a separate branch (``start_batch_re_interview``):
it carries no project/product of its own, so its scope is recovered from
its root-subtasks instead.
"""
@@ -406,9 +336,9 @@ async def re_interview(
entries = await get_journal_service(db).board_review_brief(task_id)
if is_batch_umbrella(batch_id=task.batch_id, parent_task_id=task.parent_task_id):
return await _start_batch_re_interview(db, task, entries)
return await start_batch_re_interview(db, task, entries)
project_slug, product_id = await _intake_scope_for_task(db, task)
project_slug, product_id = await intake_scope_for_task(db, task)
if not project_slug and not product_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+7 -30
View File
@@ -4,7 +4,6 @@ BACKLOG task; nothing here starts it — normal PM activation takes it from
there.
"""
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
@@ -13,40 +12,16 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.roadmap import (
RoadmapCycleResponse,
RoadmapItemActionResponse,
RoadmapItemResponse,
RoadmapRejectRequest,
task_to_roadmap_cycle_response,
)
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services.roadmap_service import get_roadmap_service
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or act on the roadmap 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") -> RoadmapCycleResponse:
payload = markers.get_roadmap_cycle(task) or {}
items = [RoadmapItemResponse(**item) for item in payload.get("items", [])]
return RoadmapCycleResponse(
task_id=str(task.id),
title=task.title,
status=_status_value(task),
goal=str(payload.get("goal") or ""),
items=items,
)
@router.get("/cycles", response_model=list[RoadmapCycleResponse])
async def list_roadmap_cycles(
db: DbSession, agent: CurrentAgentContext
@@ -56,9 +31,11 @@ async def list_roadmap_cycles(
A cycle the PO hasn't authored yet (no items drafted) is omitted — there
is nothing for the CEO to review until ``propose_roadmap`` lands.
"""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the roadmap queue")
tasks = await get_roadmap_service(db).list_open_cycles()
return [_to_response(t) for t in tasks if markers.get_roadmap_cycle(t)]
return [
task_to_roadmap_cycle_response(t) for t in tasks if markers.get_roadmap_cycle(t)
]
@router.post(
@@ -74,7 +51,7 @@ async def approve_roadmap_item(
agent: CurrentAgentContext,
) -> RoadmapItemActionResponse:
"""Materialize one proposed item as a BACKLOG task (idempotent)."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the roadmap queue")
result = await get_roadmap_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id
)
@@ -108,7 +85,7 @@ async def reject_roadmap_item(
agent: CurrentAgentContext,
) -> RoadmapItemActionResponse:
"""Reject one proposed item with a reason (idempotent)."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the roadmap queue")
result = await get_roadmap_service(db).reject_item(task_id, item_id, data.reason)
if result is None:
raise HTTPException(
+37 -550
View File
@@ -4,12 +4,10 @@ Task API Routes
Full CRUD operations and lifecycle management for tasks.
"""
from dataclasses import dataclass
from typing import Annotated, Any, cast
from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.deps import (
CurrentAgentContext,
@@ -23,8 +21,6 @@ from roboco.api.schemas.tasks import (
CancelTaskRequest,
CheckpointRequest,
ClaimRequest,
CollisionMapResponse,
CollisionSibling,
CommitRequest,
CompleteTaskRequest,
EscalateRequest,
@@ -48,7 +44,6 @@ from roboco.api.schemas.tasks import (
task_to_response,
transform_update_data,
)
from roboco.db.tables import TaskTable
from roboco.enforcement import get_valid_transitions
from roboco.exceptions import GitError, TaskLifecycleError
from roboco.foundation.policy import task_completeness as tc
@@ -61,169 +56,48 @@ from roboco.security import (
secret_exfil_validator,
)
from roboco.services.audit import get_audit_service
from roboco.services.base import (
NotFoundError,
ServiceError,
UnauthorizedError,
ValidationError,
)
from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.base import ServiceError
from roboco.services.journal import get_journal_service
from roboco.services.notification_delivery import (
EscalationError,
get_notification_delivery_service,
)
from roboco.services.permissions import AgentContext, TaskAction
from roboco.services.permissions import TaskAction
from roboco.services.repositories.review_findings import ReviewFindingsRepository
from roboco.services.task import (
SoftBlockInput,
StatusOverride,
TaskCreateRequest,
TaskService,
apply_forced_status_override,
apply_null_clears,
enforce_pm_lighter_fields,
extract_original_developer,
get_task_service,
merge_pr_if_awaiting_pm_review,
pm_editor_scope,
pop_null_clears,
reassert_batch_shape,
resolve_assigned_to_slug,
resolve_project_for_merge,
translate_task_error,
)
from roboco.utils.converters import require_uuid
router = APIRouter()
_logger = get_logger(__name__)
# #13: lifecycle-bypass hatch states — a privileged PATCH into one of these is a
# forced override that must carry the explicit ``force`` acknowledgement flag.
# The set covers every gate / terminal state a panel drag could paste a task
# into, bypassing the human gate that state represents: COMPLETED (the merge
# decision), AWAITING_QA / AWAITING_DOCUMENTATION / AWAITING_PR_REVIEW /
# AWAITING_PM_REVIEW / AWAITING_CEO_APPROVAL (the review/merge/CEO gates),
# and CANCELLED (the terminal cancel). Without force these are refused so the
# bypass is always an explicit, audited, acknowledged override — never a quiet
# panel click that drops a task into (or out of) a gate.
_HATCH_OVERRIDE_STATES = frozenset(
{
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
}
)
# Terminal statuses — a privileged PATCH OUT of one of these resurrects
# finished/cancelled work, which must also carry the explicit ``force``
# acknowledgement (mirrors the escalate route's refusal to resurrect).
_RESURRECT_SOURCE_STATES = frozenset({TaskStatus.COMPLETED, TaskStatus.CANCELLED})
@dataclass(frozen=True, slots=True)
class _StatusOverride:
"""Bundle of ``update_task`` override params (keeps the helper ≤ 5 args)."""
service: TaskService
task_id: UUID
task: TaskTable
new_status: TaskStatus
force: bool
has_higher_perms: bool
agent: AgentContext
async def _refuse_unforced_complete_with_open_pr(req: _StatusOverride) -> None:
"""Admin-complete must merge-or-refuse.
Completing a task whose PR is still OPEN strands its commits unmerged
(bit the CEO twice live, 2026-07-02). Checked before the generic hatch
text so the refusal names the PR and the consequence instead of a vague
gate message; ``force`` stays the deliberate, audited escape.
"""
if req.new_status != TaskStatus.COMPLETED or req.force:
return
open_ws = await req.service.open_pr_ref(req.task)
if open_ws is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Task still has OPEN PR #{open_ws.pr_number}"
f" ({open_ws.pr_url}); completing it now would strand"
" those commits unmerged. Merge the PR first (or approve"
" via POST /api/tasks/{id}/ceo-approve), or pass"
' "force": true to strand it deliberately.'
),
)
async def _apply_forced_status_override(req: _StatusOverride) -> TaskTable:
"""Apply an audited admin status override, gating the lifecycle bypass.
Extracted from ``update_task`` so the route's complexity stays readable.
Refuses a non-privileged caller, and refuses a bypass into a hatch state
without the explicit ``force`` flag; otherwise delegates to the audited
``admin_set_status`` and asserts the override landed.
"""
if req.new_status == req.task.status:
return req.task
if not req.has_higher_perms:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only privileged roles may override task status.",
)
await _refuse_unforced_complete_with_open_pr(req)
if req.new_status in _HATCH_OVERRIDE_STATES and not req.force:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Overriding a task into "
f"{req.new_status.value} bypasses the lifecycle gate; pass "
'"force": true to acknowledge the forced override.'
),
)
# Resurrecting a terminal task (completed / cancelled -> anything) is a
# bypass of the merge / cancel decision; it too requires the explicit force
# acknowledgement. The target-only hatch gate above misses this because the
# target (e.g. in_progress) is not itself a hatch state.
if req.task.status in _RESURRECT_SOURCE_STATES and not req.force:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Task is in the terminal state {req.task.status.value};"
" resurrecting it past the lifecycle gate requires"
' "force": true to acknowledge the override.'
),
)
task = await req.service.admin_set_status(
req.task_id,
req.new_status,
actor_id=req.agent.agent_id,
actor_role=getattr(req.agent, "role", None),
force=req.force,
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task status override failed unexpectedly",
)
return task
# Minimum character count for notes fields that must be substantive
# (QA pass notes, doc-complete notes, escalation notes). Below this the
# note is useless for the next reader, so the transition is refused.
_MIN_NOTES_CHARS = 20
# Nullable task fields that may be explicitly cleared via PATCH.
# After TaskService.update() gains its not-None guard, null-clears for these
# fields are handled at the route layer by direct setattr on the ORM object.
_NULLABLE_TASK_FIELDS: frozenset[str] = frozenset(
{"assigned_to", "parent_task_id", "project_id", "budget_usd"}
)
# Structural / ownership fields a bare task owner (UPDATE_OWN) must NOT
# self-edit — they reassign the task, move it between teams, re-parent the task
# tree, rewire the sequencing DAG, re-route it to another repo, or rewrite the
# delegation plan. These are PM/ASSIGN-gated operations; the verb layer gates
# them to PM roles (reassign/delegate/triage), so the REST PATCH surface must
# not let an owner bypass that by setattr-ing them directly. Only a caller with
# the higher ASSIGN permission may set them. budget_usd joins this set too — a
# self-serve budget raise on your own task would defeat the whole cap.
# the higher ASSIGN permission may set them.
_PRIVILEGED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
"assigned_to",
@@ -233,320 +107,9 @@ _PRIVILEGED_UPDATE_FIELDS: frozenset[str] = frozenset(
"blocker_ids",
"plan",
"project_id",
"budget_usd",
}
)
# The CEO's "PM lighter" scope: cell_pm/main_pm may PATCH this content-only
# slice — the same allowlist the Secretary's edit directive originally had
# (before it grew to Secretary FULL). No status changes, no structural/
# ownership fields (_PRIVILEGED_UPDATE_FIELDS), no git fields — those stay on
# the lifecycle-verb surface (delegate/reassign/complete/...).
_PM_LIGHTER_UPDATE_FIELDS: frozenset[str] = frozenset(
{"title", "description", "acceptance_criteria", "priority"}
)
# Roles that get the lighter slice above instead of the full ASSIGN-holding
# admin bypass. TaskAction.ASSIGN is not team-scoped (see
# can_perform_task_action), so a cell_pm would otherwise ride the same
# unrestricted bypass CEO/Board/Auditor get, on any team's task — the
# own-team restriction below is enforced independently of that permission.
_PM_LIGHTER_ROLES: frozenset[AgentRole] = frozenset(
{AgentRole.CELL_PM, AgentRole.MAIN_PM}
)
def _pm_editor_scope(
agent: AgentContext, task: TaskTable, *, has_higher_perms: bool
) -> bool:
"""Return True if ``agent`` gets the "PM lighter" content-only slice.
Raises 403 outright for a cell PM outside its own team ASSIGN itself
is not team-scoped (see ``can_perform_task_action``), so without this
check a cross-team cell PM would fall through to the wider CEO/Board/
Auditor admin bypass on ``has_higher_perms`` alone.
"""
is_pm_editor = has_higher_perms and agent.role in _PM_LIGHTER_ROLES
if is_pm_editor and agent.role == AgentRole.CELL_PM and agent.team != task.team:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Cell PM may only update tasks belonging to their own team "
f"({agent.team}); this task is on {task.team}."
),
)
return is_pm_editor
def _enforce_pm_lighter_fields(
updates: dict[str, Any],
null_clears: dict[str, Any],
new_status: TaskStatus | None,
) -> None:
"""Refuse anything past the content-only allowlist for a PM-lighter editor.
"No status changes beyond what they already have" status rides the
lifecycle verbs, never this PATCH surface, for cell_pm/main_pm.
"""
disallowed = (updates.keys() | null_clears.keys()) - _PM_LIGHTER_UPDATE_FIELDS
if not disallowed and new_status is None:
return
reasons = []
if disallowed:
reasons.append(f"disallowed fields {sorted(disallowed)}")
if new_status is not None:
reasons.append("status changes are not part of the PM PATCH surface")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"PM roles may only edit {sorted(_PM_LIGHTER_UPDATE_FIELDS)} via "
"PATCH; " + "; ".join(reasons)
),
)
def _translate_error(e: ServiceError) -> HTTPException:
"""Service errors → HTTP status. Kept at route layer; everything else moves."""
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)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message
)
# ---------------------------------------------------------------------------
# Route-layer helpers — extracted to keep the three complex routes ≤ rank B.
# ---------------------------------------------------------------------------
def _task_is_awaiting_pm_review(task: Any) -> bool:
"""Return True if the task is in the awaiting_pm_review state."""
from roboco.models.base import TaskStatus as _TS
return (
task.status == _TS.AWAITING_PM_REVIEW
or getattr(task.status, "value", None) == "awaiting_pm_review"
)
def _pop_null_clears(updates: dict[str, Any]) -> dict[str, None]:
"""Remove and return explicitly-set-to-None nullable fields from *updates*.
TaskService.update() skips None values (not-None guard), so null-clearing
a field must be done at the route layer. This helper splits the intent:
it pops the null-clears from *updates* (modifying it in-place) and returns
them so the caller can apply them directly on the ORM object.
"""
clears: dict[str, None] = {}
for field in _NULLABLE_TASK_FIELDS:
if field in updates and updates[field] is None:
clears[field] = updates.pop(field)
return clears
def _apply_null_clears(task: Any, null_clears: dict[str, None]) -> None:
"""Set *null_clears* fields to None on the ORM task object.
Unassigning implies releasing the claim: a cleared assigned_to with a
surviving claimed_by/active_claimant_id keeps routing the task to the
stale claimant while the next agent's content writes bounce.
"""
for field in null_clears:
setattr(task, field, None)
if "assigned_to" in null_clears:
task.claimed_by = None
task.claimed_at = None
task.active_claimant_id = None
def _reassert_batch_shape(task: Any) -> None:
"""Raise HTTP 400 if a mutation broke the task's MegaTask shape. Raised
before any commit, so a violation rolls back cleanly."""
from roboco.services.task import TaskService
try:
TaskService.assert_batch_shape_intact(task)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
async def _resolve_assigned_to_slug(
data: "TaskUpdate", db: AsyncSession
) -> "TaskUpdate":
"""Resolve an assigned_to slug to a UUID string; returns (possibly modified) data.
If assigned_to was not set or is already a valid UUID or null, returns
*data* unchanged. If it is an agent slug, looks up the agent and replaces
the slug with the UUID string so downstream transform helpers parse it
correctly. Raises HTTPException 422 when the slug cannot be found.
"""
if "assigned_to" not in data.model_fields_set or data.assigned_to is None:
return data
try:
UUID(data.assigned_to)
return data # already a valid UUID — no resolution needed
except ValueError:
pass
from roboco.services.repositories.query_helpers import get_agent_by_slug
agent_row = await get_agent_by_slug(db, data.assigned_to)
if agent_row is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"error": {
"code": "ASSIGNEE_NOT_FOUND",
"message": f"No agent with slug or UUID '{data.assigned_to}'",
"hint": "Use an agent slug (e.g. 'be-dev-1') or UUID",
}
},
) from None
return data.model_copy(update={"assigned_to": str(agent_row.id)})
def _first_cell_map_project_id(task: Any) -> UUID | None:
"""First distinct project_id from a task's ad-hoc per-cell map.
Mirrors the product-root ``distinct_project_ids(...)[0]`` first-project
resolution: dedupes by project_id (a monorepo mapped across cells shares
one project), ordered by cell team for determinism. Returns None when the
task carries no cell map.
"""
cell_map = getattr(task, "cell_projects", None) or []
seen: set[UUID] = set()
for mapping in sorted(cell_map, key=lambda m: m.team.value):
pid = UUID(str(mapping.project_id))
if pid not in seen:
seen.add(pid)
return pid
return None
async def _project_for_complete(task: Any, db: AsyncSession) -> Any:
"""Resolve the project for complete_task's pre-merge step.
Returns the project or None if unresolvable (no exception raised the
caller simply skips the merge when no project can be found).
"""
from roboco.services.project import get_project_service
project_service = get_project_service(db)
if task.project_id is not None:
return await project_service.get(UUID(str(task.project_id)))
if task.product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(db)
pids = await product_service.distinct_project_ids(UUID(str(task.product_id)))
if pids:
return await project_service.get(pids[0])
cell_pid = _first_cell_map_project_id(task)
if cell_pid is not None:
return await project_service.get(cell_pid)
return None
async def _merge_pr_if_awaiting_pm_review(
task_id: UUID,
pre_task: Any,
agent: Any,
db: AsyncSession,
) -> None:
"""Merge the task's PR when it is in awaiting_pm_review.
Does nothing when pre_task is None, has no PR, or is not in the right
state. Raises HTTPException 400 when the merge itself fails.
After this returns successfully, *_auto_complete_on_merge* inside the
git service will have already transitioned the task to *completed*.
"""
if pre_task is None or pre_task.pr_number is None:
return
if not _task_is_awaiting_pm_review(pre_task):
return
project = await _project_for_complete(pre_task, db)
if project is None:
return
from roboco.api.schemas.git import GitMergePRRequest
from roboco.services.git import get_git_service
git_service = get_git_service(db)
try:
await git_service.merge_pr_for_task(
agent.agent_id,
agent.role,
GitMergePRRequest(
project_slug=project.slug,
pr_number=pre_task.pr_number,
task_id=task_id,
merge_method="squash",
),
)
except (ServiceError, GitError) as e:
msg = getattr(e, "message", str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"PR merge failed before completion: {msg}",
) from e
async def _resolve_project_for_merge(task: Any, db: AsyncSession) -> Any:
"""Resolve and return the Project required for a merge operation.
Handles both direct project_id and product_idproject resolution.
Raises HTTPException 400 if no project can be resolved or found.
"""
from roboco.services.project import get_project_service
project_service = get_project_service(db)
if task.project_id is not None:
resolved_id = UUID(str(task.project_id))
elif task.product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(db)
project_ids = await product_service.distinct_project_ids(
UUID(str(task.product_id))
)
if not project_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"NO_PROJECT: Product {task.product_id} has no cell->project "
"mapping; cannot resolve workspace for merge."
),
)
resolved_id = project_ids[0]
else:
cell_pid = _first_cell_map_project_id(task)
if cell_pid is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PROJECT: Task has neither project_id, product_id, nor a "
"cell->project map; cannot resolve workspace for merge. Set a "
"target on the task first."
),
)
resolved_id = cell_pid
project = await project_service.get(resolved_id)
if not project:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"NO_PROJECT: Project {resolved_id} not found; "
"cannot resolve workspace for merge."
),
)
return project
# =============================================================================
# CRUD ENDPOINTS
@@ -728,7 +291,6 @@ async def list_tasks(
@router.get("/summary", response_model=list[TaskSummaryResponse])
async def list_tasks_summary(
*,
db: DbSession,
agent: CurrentAgentContext,
team: Team | None = None,
@@ -1120,13 +682,6 @@ async def get_task(
# Enrich with work session and project context
response = await enrich_task_with_context(response, db)
# Gated the same as the budgets feature: an extra DB read, so only pay
# for it when the panel can actually make use of it (ROBOCO_TASK_BUDGETS_ENABLED).
from roboco.config import settings as _settings
if _settings.task_budgets_enabled:
response.spend_usd = await service.task_spend_usd(task_id)
return response
@@ -1174,8 +729,8 @@ async def update_task(
# PM roles (cell_pm/main_pm) ride the ASSIGN-holding bypass above like
# CEO/Board/Auditor, but get the narrower "PM lighter" content-only slice
# instead of unrestricted admin access (see _pm_editor_scope).
is_pm_editor = _pm_editor_scope(agent, task, has_higher_perms=has_higher_perms)
# instead of unrestricted admin access (see pm_editor_scope).
is_pm_editor = pm_editor_scope(agent, task, has_higher_perms=has_higher_perms)
if not ((can_update_own and is_owner) or has_higher_perms):
raise HTTPException(
@@ -1184,7 +739,7 @@ async def update_task(
)
# Resolve assigned_to slug → UUID (null is left for the null-clear path).
data = await _resolve_assigned_to_slug(data, db)
data = await resolve_assigned_to_slug(data, db)
# Transform input data for database storage.
updates = transform_update_data(data)
@@ -1201,13 +756,13 @@ async def update_task(
# Pop explicitly-set-to-None nullable fields. TaskService.update() skips
# None values (not-None guard), so null-clear intent is re-applied directly
# on the ORM object after the update returns.
null_clears = _pop_null_clears(updates)
null_clears = pop_null_clears(updates)
# PM-lighter: restrict to the content-only allowlist, and refuse a status
# change outright — "no status changes beyond what they already have"
# (the lifecycle verbs), not a new capability riding this PATCH surface.
if is_pm_editor:
_enforce_pm_lighter_fields(updates, null_clears, new_status)
enforce_pm_lighter_fields(updates, null_clears, new_status)
# A bare task owner (UPDATE_OWN) may edit dev-facing fields only. The
# structural / ownership fields are gated to ASSIGN/PM; an owner PATCHing
@@ -1233,14 +788,14 @@ async def update_task(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task update failed unexpectedly",
)
_apply_null_clears(task, null_clears)
apply_null_clears(task, null_clears)
# Null-clears apply AFTER service.update() (and its shape guard), so re-assert
# the MegaTask shape here too — a cleared parent_task_id / project_id must not
# turn a root-subtask into an umbrella-shaped-but-targeted spoof.
_reassert_batch_shape(task)
reassert_batch_shape(task)
if new_status is not None:
task = await _apply_forced_status_override(
_StatusOverride(
task = await apply_forced_status_override(
StatusOverride(
service=service,
task_id=task_id,
task=task,
@@ -1361,67 +916,6 @@ async def get_task_findings(
)
@router.get("/{task_id}/collision-map", response_model=CollisionMapResponse)
async def get_task_collision_map(
task_id: UUID,
db: DbSession,
_agent: CurrentAgentContext,
) -> CollisionMapResponse:
"""The reviewer/PM collision map for a task — its own declared surface
(``intends_to_touch`` / ``adds_migration`` / ``touches_shared``) plus
the surfaced siblings (same parent) that would collide with it: file
globs that overlap or a shared migration chain. Read-only feed for the
panel's Collision tab; the QA/PR-gate evidence envelopes carry the same
block inline (with declared-vs-actual drift, which needs the real
touched files the panel route doesn't resolve a workspace for).
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# No actual files here — the panel shows the declared surface + sibling
# overlap only; drift stays in the in-context evidence envelope.
# Best-effort: a fetch/build failure degrades to no siblings rather than
# a 500 — the route still returns the task's own declared surface.
ctx: list[dict[str, Any]] | None = None
try:
siblings = (
await service.get_subtasks(UUID(str(task.parent_task_id)))
if task.parent_task_id
else []
)
ctx = build_collision_context(task=task, siblings=siblings)
except Exception as exc:
_logger.warning(
"collision_map_route_skip", task_id=str(task.id), error=str(exc)
)
return CollisionMapResponse(
task_id=str(task.id),
parent_task_id=str(task.parent_task_id) if task.parent_task_id else None,
intends_to_touch=list(task.intends_to_touch or []),
adds_migration=bool(task.adds_migration),
touches_shared=bool(task.touches_shared),
siblings=[
CollisionSibling(
id=s["id"],
title=s.get("title"),
status=s.get("status", ""),
branch_name=s.get("branch_name"),
pr_number=s.get("pr_number"),
sequence=s.get("sequence"),
intends_to_touch=s.get("intends_to_touch", []),
adds_migration=s.get("adds_migration", False),
touches_shared=s.get("touches_shared", False),
overlap=s.get("overlap", []),
undeclared=s.get("undeclared", []),
)
for s in (ctx or [])
],
)
# =============================================================================
# LIFECYCLE ENDPOINTS
# =============================================================================
@@ -1446,7 +940,7 @@ async def claim_task(
claim_target_slug=(data.agent_id if data else None),
)
except ServiceError as e:
raise _translate_error(e) from e
raise translate_task_error(e) from e
return task_to_response(task)
@@ -1571,7 +1065,7 @@ async def soft_block_task(
),
)
except ServiceError as e:
raise _translate_error(e) from e
raise translate_task_error(e) from e
return task_to_response(task)
@@ -1630,16 +1124,11 @@ async def pause_task(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only the assigned agent or the CEO can pause a task. The lifecycle
# spec's in_progress->paused transition carries no role restriction of
# its own (enforced upstream by the gateway's flow verbs, which never
# expose pause to agents at all) — this route is the sole gate, and the
# CEO carve-out here is deliberately narrower than unblock/block's
# (assignee-or-{CELL_PM, MAIN_PM, CEO}): pause has no PM-role carve-out.
if task.assigned_to != agent.agent_id and agent.role != AgentRole.CEO:
# Only assigned agent can pause their task
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent or the CEO can pause this task",
detail="Only the assigned agent can pause this task",
)
task = await service.pause(task_id, agent.role)
@@ -1667,13 +1156,11 @@ async def resume_task(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only the assigned agent or the CEO can resume a task — same carve-out
# as pause above, so a CEO who paused a task through the front door can
# also resume it through the front door.
if task.assigned_to != agent.agent_id and agent.role != AgentRole.CEO:
# Only assigned agent can resume their task
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent or the CEO can resume this task",
detail="Only the assigned agent can resume this task",
)
task = await service.resume(task_id, agent.role)
@@ -1966,7 +1453,7 @@ async def docs_complete(
try:
task = await service.docs_complete_for_task(task_id, agent, notes=data.notes)
except ServiceError as e:
raise _translate_error(e) from e
raise translate_task_error(e) from e
return task_to_response(task)
@@ -2071,7 +1558,7 @@ async def complete_task(
# to completed automatically; re-fetch and detect that to avoid a
# double-completion error.
pre_task = await service.get(task_id)
await _merge_pr_if_awaiting_pm_review(task_id, pre_task, agent, db)
await merge_pr_if_awaiting_pm_review(task_id, pre_task, agent, db)
# Re-fetch: if the merge auto-completed the task, return without a second call.
merged_task = await service.get(task_id)
@@ -2087,7 +1574,7 @@ async def complete_task(
justification=justification,
)
except ServiceError as e:
raise _translate_error(e) from e
raise translate_task_error(e) from e
return task_to_response(task)
@@ -2164,7 +1651,7 @@ async def escalate_to_ceo(
task_id, agent, permissions, notes=(data.notes if data else None)
)
except ServiceError as e:
raise _translate_error(e) from e
raise translate_task_error(e) from e
return task_to_response(task)
@@ -2303,7 +1790,7 @@ async def approve_and_merge_task(
)
# Resolve the project from the task's project_id / product_id.
project = await _resolve_project_for_merge(task, db)
project = await resolve_project_for_merge(task, db)
from roboco.api.schemas.git import GitMergePRRequest
from roboco.services.git import get_git_service
@@ -2596,7 +2083,7 @@ async def substitute_task(
task_id, agent, reason_raw=data.reason, details=data.details
)
except ServiceError as e:
raise _translate_error(e) from e
raise translate_task_error(e) from e
return task_to_response(task)
+21 -118
View File
@@ -1,55 +1,33 @@
"""Role-asserting dependencies and shared helpers for v1 flow routers.
"""Role-asserting dependency bindings for v1 flow routers.
Every router gets one of these as a dependency so the role check happens
before the choreographer body even runs. Defense in depth the
choreographer also re-checks role internally for verbs that branch on it.
The actual guard functions (``require_roles`` / ``require_authenticated_agent``
/ ``envelope_to_response``) live in ``roboco.api.deps`` this module only
binds them to the per-role frozensets so callers get a ready-to-use
``Depends`` value.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, Any, cast
import structlog
from fastapi import Depends, Header, HTTPException, params, status
from roboco.api.deps import envelope_to_response, require_authenticated_agent
from roboco.api.deps import require_roles as _require_roles
from roboco.foundation.identity import Role
logger = structlog.get_logger(__name__)
if TYPE_CHECKING:
from fastapi import Request
from roboco.services.gateway.envelope import Envelope
def _require_roles(allowed: frozenset[Role]) -> params.Depends:
def _check(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str, Header(alias="X-Agent-Role")],
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
) -> None:
# Bind the role header to a verified token BEFORE trusting it. These v1
# flow guards are the sole gate for the /api/v1/flow/* endpoints, but
# previously checked only the role string — unlike get_agent_context,
# which already verifies the token. So a forged X-Agent-Role passed, and
# in strict mode (ROBOCO_AGENT_AUTH_REQUIRED) the token was never
# required here. In header-trust (dev) mode a missing token stays a
# no-op; any presented token is still verified. Deferred import avoids
# an import cycle with routers that import both this module and deps.
from roboco.api.deps import _check_agent_auth_token
_check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token)
# `Role` is a StrEnum, so the lowercase header string compares equal
# to its matching member.
if x_agent_role.lower() not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{x_agent_role}' not allowed for this endpoint group",
)
return cast("params.Depends", Depends(_check))
__all__ = [
"envelope_to_response",
"require_any_authenticated_agent",
"require_auditor",
"require_board",
"require_cell_pm",
"require_dev",
"require_doc",
"require_main_pm",
"require_pr_reviewer",
"require_qa",
]
# Role-typed single-role guards — renaming a role edits foundation.identity only.
# `require_board` is the only multi-role guard (Product Owner + Head of Marketing
@@ -63,80 +41,5 @@ require_board = _require_roles(frozenset({Role.PRODUCT_OWNER, Role.HEAD_MARKETIN
require_auditor = _require_roles(frozenset({Role.AUDITOR}))
require_pr_reviewer = _require_roles(frozenset({Role.PR_REVIEWER}))
def _require_authenticated_agent() -> params.Depends:
"""Token-only guard for the content-tool (do) router.
The do router serves every role content tools are role-uniform, with
per-role removal handled in the spawn manifest so, unlike the flow
routers, there is no single role to assert. But it must still bind the
presented ``X-Agent-ID`` to a verified HMAC token when
``ROBOCO_AGENT_AUTH_REQUIRED=true`` and reject a forged token even in
dev mode, exactly as the flow role guards do. Without this the
``/api/v1/do/*`` endpoints were the one agent-gateway path that
accepted a forged ``X-Agent-ID`` with no token check a weaker gate
than ``/api/v1/flow/*``. The role/team headers are optional (the do
MCP server sends role but not team); they only feed the HMAC payload,
so a missing team is the empty-string team the token was issued with.
"""
def _check(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str | None, Header(alias="X-Agent-Role")] = None,
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
) -> None:
from roboco.api.deps import _check_agent_auth_token
_check_agent_auth_token(
x_agent_id, x_agent_role or "", x_agent_team, x_agent_token
)
return cast("params.Depends", Depends(_check))
# The do router serves all roles, so this is token-only (no role assertion).
require_any_authenticated_agent = _require_authenticated_agent()
def envelope_to_response(env: Envelope, request: Request) -> dict[str, Any]:
"""Stamp the request's correlation_id onto the envelope and return wire-dict.
``CorrelationIdMiddleware`` writes the inbound (or freshly-generated)
``X-Correlation-ID`` to ``request.state.correlation_id``. We pull it
here so the agent receives the same id it sent (or can capture the
server-generated one) and ops can join logs across the full
MCP -> API -> service hop.
"""
cid = getattr(request.state, "correlation_id", None)
if cid is not None and env.correlation_id is None:
env.correlation_id = cid
payload = env.as_dict()
_log_rejection(payload, request)
return payload
def _log_rejection(payload: dict[str, Any], request: Request) -> None:
"""Log a rejected envelope's reason at the single wire chokepoint.
Rejections used to leave NO server-side trace: the access log records
``POST /api/v1/do/<verb> 200`` (an error envelope is still a 200), the
envelope body is never logged, and there is no trace table so a verb
an agent could not satisfy was indistinguishable in the logs from one
that succeeded. Four Board Programs died that way on 2026-07-25 and the
reason was unrecoverable after the fact.
"""
error = payload.get("error")
if not error:
return
logger.warning(
"verb rejected",
verb=request.url.path.rsplit("/", 1)[-1],
error=error,
detail=payload.get("message"),
remediate=payload.get("remediate"),
missing=payload.get("missing"),
agent_id=request.headers.get("X-Agent-ID"),
agent_role=request.headers.get("X-Agent-Role"),
task_id=payload.get("task_id"),
)
require_any_authenticated_agent = require_authenticated_agent()
+28 -287
View File
@@ -6,18 +6,14 @@ API never returns plaintext)."""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import cast
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import FileResponse, StreamingResponse
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.project_fields import task_project_fields
from roboco.api.schemas.video import (
PreviewFrameResponse,
TikTokCredentialsSetRequest,
TikTokCredentialsStatus,
VideoPipelineItemResponse,
@@ -26,35 +22,29 @@ from roboco.api.schemas.video import (
VideoPostHistoryResponse,
VideoPostRejectRequest,
VideoPostResponse,
VideoPreviewFramesResponse,
VideoRequestBody,
VideoRequestResponse,
task_to_pipeline_item,
task_to_video_post_history_response,
task_to_video_post_response,
)
from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services import minio_client
from roboco.services.project import get_project_service
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from roboco.services.tiktok_client import build_tiktok_poster
from roboco.services.tiktok_credentials import (
TikTokCredentialsValidationError,
get_tiktok_credentials_service,
)
from roboco.services.video_engine import get_video_engine
from roboco.services.video_engine import get_video_engine, resolve_preview_path
from roboco.services.video_post_service import (
VideoCaptionTooLongError,
build_real_video_post_service,
get_video_post_service,
resolve_video_cut,
)
from roboco.services.workspace import WorkspaceError, get_workspace_service
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_video_client import build_x_video_poster
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable, TaskTable
from roboco.services.video_post_service import VideoPostService
router = APIRouter()
tiktok_router = APIRouter()
@@ -62,29 +52,6 @@ tiktok_router = APIRouter()
_VALID_CUTS = ("vertical", "square")
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or act on the video engine")
def _resolve_video_cut(task: TaskTable, cut: str) -> Path:
"""Resolve the on-disk MP4 path for ``cut`` off the task's held draft, or
404. The ``is_relative_to`` confinement check stays even though the MinIO
key is a basename (traversal-proof) it also guards the ``FileResponse``
fallback path that reads ``mp4_path`` straight from disk."""
draft = markers.get_video_draft(task) or {}
mp4_path = (draft.get("mp4_paths") or {}).get(cut)
if not mp4_path or not Path(mp4_path).is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut"
)
output_dir = Path(settings.video_output_dir).resolve()
if not Path(mp4_path).resolve().is_relative_to(output_dir):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut"
)
return Path(mp4_path)
@router.post("/request", response_model=VideoRequestResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.block_clouds()
@@ -105,7 +72,7 @@ async def request_video(
reason (a duplicate occasion or the open-post cap) not an error, just
nothing to do.
"""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
if not settings.video_engine_enabled:
return VideoRequestResponse(
status="disabled",
@@ -140,72 +107,14 @@ async def request_video(
)
def _status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def _to_response(task: TaskTable) -> VideoPostResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPostResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_status_value(task),
occasion=str(draft.get("occasion") or ""),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
x_caption=draft.get("x_caption"),
tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task),
mp4_paths=dict(draft.get("mp4_paths") or {}),
source_task_id=draft.get("source_task_id"),
project_slug=project_slug,
project_name=project_name,
)
async def _real_video_post_service(db: AsyncSession) -> VideoPostService:
"""A VideoPostService wired with the real posters, built from stored
credentials. Only ``approve`` needs live posters list/reject never
call one, so they use the inert Null defaults (``get_video_post_service
(db)``) instead."""
x_creds = await get_x_credentials_service(db).get_decrypted()
x_poster = build_x_video_poster(x_creds, timeout=settings.x_request_timeout_seconds)
tiktok_creds = await get_tiktok_credentials_service(db).get_decrypted()
tiktok_poster = build_tiktok_poster(
tiktok_creds, session=db, timeout=settings.video_request_timeout_seconds
)
return get_video_post_service(db, x_poster=x_poster, tiktok_poster=tiktok_poster)
@router.get("/posts", response_model=list[VideoPostResponse])
async def list_video_posts(
db: DbSession, agent: CurrentAgentContext
) -> list[VideoPostResponse]:
"""Every held video_post draft (rendered clip) awaiting the CEO."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
tasks = await get_video_post_service(db).list_held_video_posts()
return [_to_response(t) for t in tasks]
def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPipelineItemResponse(
task_id=str(task.id),
title=task.title,
occasion=str(draft.get("occasion") or ""),
status=_status_value(task),
pr_number=task.pr_number,
composition_id=draft.get("composition_id"),
render_status=draft.get("render_status"),
render_attempts=int(draft.get("render_attempts", 0)),
render_error=draft.get("render_error"),
project_slug=project_slug,
project_name=project_name,
)
return [task_to_video_post_response(t) for t in tasks]
@router.get("/pipeline", response_model=list[VideoPipelineItemResponse])
@@ -216,9 +125,9 @@ async def list_video_pipeline(
render loop's retry/failure states — the Social page's pipeline-
visibility strip. A rendered task has already materialized its
video_post draft (visible instead via ``/posts``) and drops out here."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
tasks = await get_task_service(db).list_video_pipeline_tasks()
return [_to_pipeline_item(t) for t in tasks]
return [task_to_pipeline_item(t) for t in tasks]
@router.post("/pipeline/{task_id}/rerender", response_model=VideoPipelineItemResponse)
@@ -231,7 +140,7 @@ async def rerender_video_task(
(``render_status``/``render_attempts``/``render_error``) so the next
render cycle re-picks it up and re-renders it. 404s when there's no such
completed authoring task with a proposed composition."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
task = await get_video_engine(db).rerender(task_id)
if task is None:
raise HTTPException(
@@ -239,20 +148,7 @@ async def rerender_video_task(
detail="No such completed video task with a proposed composition",
)
await db.commit()
return _to_pipeline_item(task)
def _resolve_preview_path(root: Path, file_path: str) -> Path | None:
"""Resolve ``file_path`` against the workspace ``root``, refusing
anything that escapes it. A leading ``/`` is stripped before joining
pathlib's ``/`` operator otherwise lets an absolute right operand
discard ``root`` entirely then the joined path must resolve to an
existing file still under ``root``. The confinement check shared by the
CEO composition-HTML proxy and the preview-frame streamer below."""
candidate = (root / file_path.lstrip("/")).resolve()
if not candidate.is_relative_to(root) or not candidate.is_file():
return None
return candidate
return task_to_pipeline_item(task)
@router.get("/preview/{task_id}/{file_path:path}", response_model=None)
@@ -266,11 +162,11 @@ async def get_video_preview(
(kit/public/etc.) straight off its project's merged read-clone — the
panel's live preview iframe. ``file_path`` is relative to the resolved
workspace root (e.g. ``motion/compositions/<id>/vertical.html``);
confined there so it can't traverse out, per ``_resolve_preview_path``.
confined there so it can't traverse out, per ``resolve_preview_path``.
CEO-only; the response carries explicit iframe-permitting headers so the
panel can embed it.
"""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
task = await get_task_service(db).get(task_id)
if task is None or task.source != VIDEO_SOURCE or task.project_id is None:
raise HTTPException(
@@ -285,7 +181,7 @@ async def get_video_preview(
workspace = await get_workspace_service(db).ensure_read_clone(project.slug)
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
resolved = _resolve_preview_path(workspace.resolve(), file_path)
resolved = resolve_preview_path(workspace.resolve(), file_path)
if resolved is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such preview file"
@@ -299,161 +195,6 @@ async def get_video_preview(
)
# request_render's self-describing filename (video-renderer/render.js):
# frame-<idx>-of-<n>-at-<t>s.png — no manifest needed to recover order/timestamp.
_FRAME_NAME_RE = re.compile(r"^frame-(\d+)-of-\d+-at-([\d.]+)s\.png$")
def _previews_root(project_slug: str, task_id: UUID) -> Path:
"""The container-shared dir request_render extracts frames to — same
path every agent container mounts (content_actions._render_extract_frames),
so this resolves identically regardless of who rendered. Resolved (like
the sibling composition-preview route resolves its workspace root before
calling ``_resolve_preview_path``) otherwise a symlinked
``workspaces_root`` makes ``candidate.is_relative_to(root)`` mismatch and
every legit frame 404s."""
return (
Path(settings.workspaces_root) / project_slug / ".previews" / task_id.hex[:8]
).resolve()
def _list_orientation_frames(dir_path: Path) -> list[PreviewFrameResponse]:
"""Sorted, filename-parsed frames for one orientation dir. Empty when
that orientation was never rendered (dir missing) the directory
listing is authoritative for both orientations at once; the
render_preview marker only ever reflects the last request_render call's
single orientation."""
if not dir_path.is_dir():
return []
frames = []
for p in dir_path.iterdir():
m = _FRAME_NAME_RE.match(p.name)
if m:
frames.append(
PreviewFrameResponse(
index=int(m.group(1)),
file=p.name,
timestamp_seconds=float(m.group(2)),
)
)
return sorted(frames, key=lambda f: f.index)
async def _resolve_video_task_project(
task_id: UUID, db: AsyncSession
) -> tuple[TaskTable, ProjectTable]:
"""Task + project resolution shared by the two preview-frame routes below
mirrors get_video_preview's inline checks (source=video, has a
project, project has a slug)."""
task = await get_task_service(db).get(task_id)
if task is None or task.source != VIDEO_SOURCE or task.project_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such video task"
)
project = await get_project_service(db).get(cast("UUID", task.project_id))
if project is None or not project.slug:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
return task, project
@router.get("/preview-frames/{task_id}", response_model=VideoPreviewFramesResponse)
async def get_video_preview_frames(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> VideoPreviewFramesResponse:
"""A video-authoring task's request_render preview — every extracted
frame per orientation, ordered, with its in-video timestamp. The only
thing the CEO has to look at before the post-completion render loop
produces the real MP4 an awaiting_ceo_approval task otherwise has
nothing to preview. 404s when there's no such video task, or nothing
was ever rendered.
"""
_require_ceo(agent)
task, project = await _resolve_video_task_project(task_id, db)
root = _previews_root(project.slug, task_id)
frames = {cut: _list_orientation_frames(root / cut) for cut in _VALID_CUTS}
if not any(frames.values()):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No render preview frames for this task",
)
preview = markers.get_render_preview(task) or {}
return VideoPreviewFramesResponse(
task_id=str(task.id),
composition_id=preview.get("composition_id"),
duration_seconds=preview.get("duration_seconds"),
head_sha=preview.get("head_sha"),
dirty=preview.get("dirty"),
rendered_at=preview.get("at"),
frames=frames,
)
@router.get("/preview-frames/{task_id}/{orientation}/{filename}", response_model=None)
async def get_video_preview_frame(
task_id: UUID,
orientation: str,
filename: str,
db: DbSession,
agent: CurrentAgentContext,
) -> FileResponse:
"""Stream one extracted preview-frame PNG. Confinement mirrors
``_resolve_preview_path`` (get_video_preview's composition-HTML proxy) —
same root-relative resolve + ``..``/escape rejection + is_file check,
scoped to this task's ``.previews/`` dir instead of its read-clone.
400 on an orientation outside {vertical, square}; 404 on a missing
task/frame.
"""
_require_ceo(agent)
if orientation not in _VALID_CUTS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"orientation must be one of {_VALID_CUTS!r}",
)
_task, project = await _resolve_video_task_project(task_id, db)
root = _previews_root(project.slug, task_id)
resolved = _resolve_preview_path(root, f"{orientation}/{filename}")
if resolved is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such preview frame"
)
return FileResponse(resolved, media_type="image/png")
def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
suffix = "_posted_id"
return {
k[: -len(suffix)]: str(v) for k, v in draft.items() if k.endswith(suffix) and v
}
def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPostHistoryResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_status_value(task),
occasion=str(draft.get("occasion") or ""),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
x_caption=draft.get("x_caption"),
tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task),
posted=_posted_ids(draft),
acted_at=task.updated_at or task.created_at,
source_task_id=draft.get("source_task_id"),
project_slug=project_slug,
project_name=project_name,
)
@router.get("/posts/history", response_model=list[VideoPostHistoryResponse])
async def list_video_post_history(
db: DbSession,
@@ -462,9 +203,9 @@ async def list_video_post_history(
) -> list[VideoPostHistoryResponse]:
"""Posted or rejected video_post drafts, newest-acted-first, bounded by
`limit`."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
tasks = await get_video_post_service(db).list_video_post_history(limit=limit)
return [_to_history_response(t) for t in tasks]
return [task_to_video_post_history_response(t) for t in tasks]
@router.get("/posts/{task_id}/media", response_model=None)
@@ -480,13 +221,13 @@ async def get_video_post_media(
When MinIO is configured (``minio_endpoint`` set) the route streams the
object from MinIO via ``minio_client.get_object_stream`` (key = the
basename of ``mp4_path``). Auth stays end-to-end ``_require_ceo`` is
basename of ``mp4_path``). Auth stays end-to-end ``require_ceo_role`` is
kept, no presigned URLs, no redirect so the panel's axios-blob flow is
unchanged (same URL, headers, body just chunked). Falls back to
``FileResponse`` from the local render dir when MinIO is unconfigured OR
on ``S3Error`` (old renders not yet in MinIO / MinIO down). The local file
existence + confinement checks stay as defense-in-depth."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
if cut not in _VALID_CUTS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -497,7 +238,7 @@ async def get_video_post_media(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such video draft"
)
mp4_path = _resolve_video_cut(task, cut)
mp4_path = resolve_video_cut(task, cut)
key = mp4_path.name
if minio_client.get_client() is not None:
try:
@@ -536,8 +277,8 @@ async def approve_video_post(
Idempotent: approving an already-posted draft returns ``already_posted``
without calling any poster again.
"""
_require_ceo(agent)
svc = await _real_video_post_service(db)
require_ceo_role(agent.role, action="view or act on the video engine")
svc = await build_real_video_post_service(db)
try:
result = await svc.approve(
task_id, x_caption=data.x_caption, tiktok_caption=data.tiktok_caption
@@ -568,14 +309,14 @@ async def reject_video_post(
agent: CurrentAgentContext,
) -> VideoPostResponse:
"""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 video engine")
task = await get_video_post_service(db).reject(task_id, data.reason)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such open video draft"
)
await db.commit()
return _to_response(task)
return task_to_video_post_response(task)
@tiktok_router.get("/credentials", response_model=TikTokCredentialsStatus)
@@ -583,7 +324,7 @@ async def get_tiktok_credentials(
db: DbSession, agent: CurrentAgentContext
) -> TikTokCredentialsStatus:
"""Whether the four TikTok OAuth2 secrets are stored. Never the secrets."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
has_creds = await get_tiktok_credentials_service(db).has_credentials()
return TikTokCredentialsStatus(has_credentials=has_creds)
@@ -599,7 +340,7 @@ async def set_tiktok_credentials(
data: TikTokCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext
) -> TikTokCredentialsStatus:
"""Set (or, passing all four empty, clear) the four TikTok OAuth2 secrets."""
_require_ceo(agent)
require_ceo_role(agent.role, action="view or act on the video engine")
svc = get_tiktok_credentials_service(db)
try:
has_creds = await svc.set_credentials(
+24
View File
@@ -2,8 +2,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from roboco.foundation.policy.content import markers
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class RoadmapItemResponse(BaseModel):
"""One roadmap item draft within a themed cycle."""
@@ -44,3 +51,20 @@ class RoadmapItemActionResponse(BaseModel):
item_id: str
materialized_task_id: str | None = None
detail: str
def roadmap_status_value(task: TaskTable) -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_roadmap_cycle_response(task: TaskTable) -> RoadmapCycleResponse:
payload = markers.get_roadmap_cycle(task) or {}
items = [RoadmapItemResponse(**item) for item in payload.get("items", [])]
return RoadmapCycleResponse(
task_id=str(task.id),
title=task.title,
status=roadmap_status_value(task),
goal=str(payload.get("goal") or ""),
items=items,
)
+81 -27
View File
@@ -1,14 +1,20 @@
"""Schemas for the video engine's on-demand request + CEO approval surface."""
from datetime import datetime
from typing import TYPE_CHECKING, Any
from uuid import UUID
from pydantic import BaseModel, Field
from roboco.api.schemas.project_fields import task_project_fields
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.content.markers import MAX_VIDEO_RENDER_ATTEMPTS
from roboco.services.video_post_service import MAX_TIKTOK_CAPTION_CHARS
from roboco.services.x_client import MAX_TWEET_CHARS
if TYPE_CHECKING:
from roboco.db.tables import TaskTable
class VideoRequestBody(BaseModel):
"""The CEO's on-demand video brief, scoped to a specific project."""
@@ -111,33 +117,6 @@ class VideoPipelineItemResponse(BaseModel):
project_name: str | None = None
class PreviewFrameResponse(BaseModel):
"""One extracted request_render preview frame — index/timestamp decoded
from the sidecar's self-describing filename
(``frame-<idx>-of-<n>-at-<t>s.png``, video-renderer/render.js)."""
index: int
file: str
timestamp_seconds: float
class VideoPreviewFramesResponse(BaseModel):
"""A video-authoring task's request_render preview frames, keyed by
orientation the CEO's only look at the rendered artifact before the
post-completion render loop produces the real MP4 (awaiting_ceo_approval
has nothing else to show). composition_id/duration/head_sha/dirty/
rendered_at come from the render_preview marker; an orientation absent
or empty from ``frames`` was never rendered."""
task_id: str
composition_id: str | None = None
duration_seconds: float | None = None
head_sha: str | None = None
dirty: bool | None = None
rendered_at: str | None = None
frames: dict[str, list[PreviewFrameResponse]] = Field(default_factory=dict)
class TikTokCredentialsStatus(BaseModel):
"""Whether the four OAuth2 secrets are stored. Never the secrets themselves."""
@@ -151,3 +130,78 @@ class TikTokCredentialsSetRequest(BaseModel):
client_secret: str = Field(default="")
access_token: str = Field(default="")
refresh_token: str = Field(default="")
def status_value(task: "TaskTable") -> str:
raw = task.status
return raw.value if hasattr(raw, "value") else str(raw)
def task_to_video_post_response(task: "TaskTable") -> VideoPostResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPostResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=status_value(task),
occasion=str(draft.get("occasion") or ""),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
x_caption=draft.get("x_caption"),
tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task),
mp4_paths=dict(draft.get("mp4_paths") or {}),
source_task_id=draft.get("source_task_id"),
project_slug=project_slug,
project_name=project_name,
)
def task_to_pipeline_item(task: "TaskTable") -> VideoPipelineItemResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPipelineItemResponse(
task_id=str(task.id),
title=task.title,
occasion=str(draft.get("occasion") or ""),
status=status_value(task),
pr_number=task.pr_number,
composition_id=draft.get("composition_id"),
render_status=draft.get("render_status"),
render_attempts=int(draft.get("render_attempts", 0)),
render_error=draft.get("render_error"),
project_slug=project_slug,
project_name=project_name,
)
def posted_ids(draft: dict[str, Any]) -> dict[str, str]:
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
suffix = "_posted_id"
return {
k[: -len(suffix)]: str(v) for k, v in draft.items() if k.endswith(suffix) and v
}
def task_to_video_post_history_response(task: "TaskTable") -> VideoPostHistoryResponse:
draft = markers.get_video_draft(task) or {}
project_slug, project_name = task_project_fields(task)
return VideoPostHistoryResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=status_value(task),
occasion=str(draft.get("occasion") or ""),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
x_caption=draft.get("x_caption"),
tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task),
posted=posted_ids(draft),
acted_at=task.updated_at or task.created_at,
source_task_id=draft.get("source_task_id"),
project_slug=project_slug,
project_name=project_name,
)
+23 -113
View File
@@ -30,8 +30,6 @@ from roboco.db.tables import (
)
from roboco.enforcement import A2AAccessDeniedError, validate_a2a_access
from roboco.events import Event, EventType, get_event_bus
from roboco.foundation.identity import is_spawnable_agent_slug
from roboco.models import NotificationPriority, NotificationType
from roboco.models.a2a import (
A2AAdminPairSummary,
A2AArtifact,
@@ -1346,7 +1344,6 @@ class A2AService:
model, task_id, from_agent, to_agent, skill
)
await self._materialize_vault_note(model, conv, from_agent, to_agent)
await self._maybe_wake_ceo_recipient(from_agent, to_agent, task_id)
return model
@staticmethod
@@ -1551,7 +1548,6 @@ class A2AService:
for cid in conv_ids:
await self._reset_unread_counter(cast("UUID", cid), slug)
await self.session.flush()
await self._ack_pending_wake_notifications(agent_id)
return len(convs)
async def _reset_unread_counter(self, conversation_id: UUID, slug: str) -> None:
@@ -1620,7 +1616,6 @@ class A2AService:
for cid in {cast("UUID", m.conversation_id) for m in msgs}:
await self._reset_unread_counter(cid, slug)
await self.session.flush()
await self._ack_pending_wake_notifications(agent_id)
return [
{
@@ -1938,7 +1933,6 @@ class A2AService:
model = self._msg_to_model(msg)
task_id = str(conv.task_id) if conv.task_id else None
await self._publish_a2a_message_sent(model, task_id, "ceo", to_agent, skill)
await self._maybe_wake_ceo_recipient("ceo", to_agent, task_id)
return model
@staticmethod
@@ -1980,113 +1974,29 @@ class A2AService:
except Exception as e:
logger.warning("Failed to publish A2A message event", error=str(e))
async def _maybe_wake_ceo_recipient(
self, from_slug: str, to_slug: str, task_id: str | None
) -> None:
"""Wake an offline recipient of a CEO-authored A2A message.
Agent-to-agent DMs stay pull-only (no wake deliberate, so ordinary
A2A chatter can't burn spawns); only a CEO-authored send/interject
reaches here. Reuses the legacy a2a_request NotificationTable row
that `_dispatch_a2a_work` already polls to spawn an offline target
`requires_ack=True` makes the row visible to that poll (A2A_REQUEST
defaults to requires_ack=False, invisible there) and lets the
pending-notification lookup below double as the dedup: a second CEO
message to the same still-unread recipient creates nothing more.
Best-effort any failure is logged and never breaks the send.
"""
if from_slug != "ceo" or not is_spawnable_agent_slug(to_slug):
return
# A wake only helps a role that can actually drain the DM and close
# the notification (read_a2a → _ack_pending_wake_notifications). Both
# auditor and pr_reviewer carry read_a2a now, but role tools are
# re-derived here rather than assumed, so a future role without it
# still gets the same protection: an unackable row would otherwise be
# immortal, permanently blocking future wakes via the dedup pre-check
# and driving futile respawns. Local imports: the gateway package
# cycles back into this module at module scope.
from roboco.agents_config import get_agent_role
from roboco.services.gateway.role_config import get_role_config
def resolve_reply_target(conv: A2AConversation, to_agent: str) -> None:
"""Validate the CEO's reply target against the pairwise conversation.
try:
role_tools = get_role_config(get_agent_role(to_slug)).do_tools
except KeyError:
return
if "read_a2a" not in role_tools:
return
try:
from roboco.services.notification import NotificationService
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
Raises the appropriate 400 HTTPException kept out of the route handler
to keep its cyclomatic complexity low. A2A conversations are strictly
pairwise (no N-party thread), so the CEO must address one of the two
real participants; A2A is also scoped to a task by construction
(A2AService.send requires task_id), so an untethered conversation can't
be replied into via this path.
"""
from fastapi import HTTPException, status
# Resolve via the DB (not the static AGENT_UUIDS seed map) —
# NotificationTable.to_agents is a real FK, so the row must
# match whatever id this agent actually has today.
to_uuid = await self.session.scalar(
select(AgentTable.id).where(AgentTable.slug == to_slug)
)
if to_uuid is None:
return
delivery = get_notification_delivery_service(self.session)
pending = await delivery.list_for_agent(
agent_id=to_uuid,
unread_only=False,
pending_ack_only=True,
type_filter=NotificationType.A2A_REQUEST,
limit=1,
)
if pending:
return
await NotificationService().send_a2a_notification(
task_id=task_id,
a2a_context={
"from_agent": from_slug,
"to_agent": to_slug,
"skill": "ceo_dm",
"message": (
"The CEO sent you a direct A2A message. Call "
"read_a2a() to read it, then reply with "
'dm("ceo", ...).'
),
"priority": NotificationPriority.NORMAL,
},
requires_ack=True,
)
except Exception as e:
logger.warning(
"CEO-DM wake notification failed", to_agent=to_slug, error=str(e)
)
async def _ack_pending_wake_notifications(self, agent_id: UUID) -> None:
"""Close out this agent's pending CEO-DM wake notification(s).
Called once the agent has actually drained its A2A inbox (read_a2a /
read_messages) so the notification `_maybe_wake_ceo_recipient`
created doesn't sit "pending" forever — which would otherwise
permanently suppress that helper's dedup for the next genuine wake.
Best-effort: a failure here never blocks the read.
"""
try:
from roboco.services.notification_delivery import (
get_notification_delivery_service,
)
delivery = get_notification_delivery_service(self.session)
pending = await delivery.list_for_agent(
agent_id=agent_id,
unread_only=False,
pending_ack_only=True,
type_filter=NotificationType.A2A_REQUEST,
limit=10,
)
if pending:
await delivery.bulk_acknowledge(
[cast("UUID", n.id) for n in pending], agent_id, "received"
)
except Exception as e:
logger.warning(
"Failed to ack pending A2A wake notifications",
agent_id=str(agent_id),
error=str(e),
)
if to_agent not in (conv.agent_a, conv.agent_b):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"{to_agent} is not a participant in this conversation "
f"(participants: {conv.agent_a}, {conv.agent_b})"
),
)
if conv.task_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Conversation has no linked task_id — A2A requires one",
)
+90 -25
View File
@@ -45,8 +45,11 @@ from roboco.services.base import NotFoundError, ServiceError, ValidationError
if TYPE_CHECKING:
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.schemas.prompter_live import StartLiveResponse
logger = structlog.get_logger()
# A board/advisory assignee means a product coordination root is still in board
@@ -75,19 +78,7 @@ _MIN_MEGATASK_PROJECTS = 2
# one impersonate a privileged origin (a held CEO-gated engine source, or
# "release_manager", which would even wedge the real release engine's
# one-open-proposal dedup).
_ALLOWED_DRAFT_SOURCES = frozenset(
{
"prompter",
"roadmap",
"pest_control",
"spackle",
"mirror",
"dogfood",
"periscope",
"sentinel",
"coroner",
}
)
_ALLOWED_DRAFT_SOURCES = frozenset({"prompter", "roadmap"})
# A draft whose per-cell map covers at least this many cells targets the ad-hoc
# multi-cell shape (a root-subtask with a cell->project map, no single project).
@@ -120,11 +111,6 @@ class BatchPlacement:
owning team for the whole batch; ``parent_task_id`` is the umbrella (or None
for the umbrella itself); ``batch_id`` is the shared batch identity; and
``sequence`` is the item's wave index.
``team_override`` alone (the other three left at their batch-less
defaults) is also the seam Board Program materialization uses e.g.
``RoadmapService._materialize`` to force ``team=Team.MAIN_PM`` on a
materialized coordination root without it being part of any real batch.
"""
parent_task_id: UUID | None = None
@@ -972,7 +958,6 @@ class PrompterService:
async def _rewrite_batch_children(
self,
*,
umbrella: TaskTable,
drafts: list[dict[str, Any]],
children: list[TaskTable],
@@ -1079,12 +1064,7 @@ class PrompterService:
plan = self._sequence_drafts(drafts)
wave_of = {idx: w for w, wave in enumerate(plan.waves) for idx in wave}
task_of = await self._rewrite_batch_children(
umbrella=umbrella,
drafts=drafts,
children=children,
wave_of=wave_of,
agent_id=agent_id,
agent_role=agent_role,
umbrella, drafts, children, wave_of, agent_id, agent_role
)
for a, b in plan.edges:
await task_service.add_dependency(task_of[b], task_of[a])
@@ -1765,6 +1745,91 @@ def compact_task_rows(tasks: list[TaskTable]) -> list[dict[str, Any]]:
]
# ---------------------------------------------------------------------------
# Live-intake route helpers (relocated from api/routes/prompter_live.py)
# ---------------------------------------------------------------------------
def translate_prompter_error(e: ServiceError) -> HTTPException:
"""Service error → HTTP status (mirrors the legacy prompter route)."""
from fastapi import HTTPException, status
if isinstance(e, NotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": "not_found", "message": e.message},
)
if isinstance(e, ValidationError):
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "validation_error",
"message": e.message,
"field": e.field,
},
)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "internal_error", "message": e.message},
)
async def intake_scope_for_task(
db: AsyncSession, task: Any
) -> tuple[str | None, str | None]:
"""Return (project_slug, product_id) intake scope for a task — exactly one."""
if task.product_id is not None:
return None, str(task.product_id)
if task.project_id is not None:
from roboco.services.project import get_project_service
proj = await get_project_service(db).get(UUID(str(task.project_id)))
return (proj.slug if proj else None), None
return None, None
async def start_batch_re_interview(
db: AsyncSession, umbrella: Any, entries: list[dict[str, Any]]
) -> StartLiveResponse:
"""Cold re-interview for a MegaTask umbrella.
Recovers the batch's multi-repo scope from its root-subtasks' own project /
cell-map targets (no single project/product lives on the branchless
umbrella) and seeds a batch-aware redraft message. 400 only when nothing is
recoverable (e.g. every root-subtask was itself cancelled).
"""
from fastapi import HTTPException, status
from roboco.api.deps import get_orchestrator
from roboco.api.schemas.prompter_live import StartLiveResponse
from roboco.services.task import get_task_service
task_service = get_task_service(db)
umbrella_id = UUID(str(umbrella.id))
children = await task_service.get_live_subtasks(umbrella_id)
project_ids = await task_service.distinct_projects_for_batch(umbrella_id)
if not project_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This MegaTask has no recoverable projects to re-interview against.",
)
initial_message = compose_batch_redraft_message(umbrella, children, entries)
session_id = uuid4().hex
try:
await get_orchestrator().start_intake_session(
session_id,
project_ids=[str(pid) for pid in project_ids],
initial_message=initial_message,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start re-interview session: {exc}",
) from exc
return StartLiveResponse(session_id=session_id, project_ids=project_ids)
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
+695 -1547
View File
File diff suppressed because it is too large Load Diff
+33 -278
View File
@@ -29,8 +29,6 @@ from roboco.foundation.policy.content import markers
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.heartbeat_mutex import HeartbeatLockUnavailable, HeartbeatMutex
from roboco.services.notification_delivery import get_notification_delivery_service
from roboco.services.project import get_project_service
from roboco.services.task import (
VIDEO_POST_SOURCE,
@@ -40,6 +38,7 @@ from roboco.services.task import (
)
if TYPE_CHECKING:
from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
@@ -51,50 +50,22 @@ _AUTHORING_ACCEPTANCE_CRITERIA = [
"Captions within platform limits",
"Composition follows motion/README.md's design bar and uses the "
"panel-demo kit register where the occasion shows the product",
"request_render preview frames verified — every scene in the brief "
"appears fully and legibly in the rendered cut",
]
_POST_ACCEPTANCE_CRITERIA = ["CEO approves or rejects the draft"]
# Mirrors task_completeness._AC_MAX_ITEMS / _AC_MAX_ITEM_CHARS (mig 068) —
# duplicated locally per this file's no-cross-service-internals idiom (see
# vault_intake_engine.py / api/schemas/v1/flow.py for the same pattern).
_AC_MAX_ITEMS = 7
_AC_MAX_ITEM_CHARS = 200
# reauthor_from_rejection's fallback AC when the original brief named no
# enumerable features — the rejection reason is already verbatim in the brief.
_REAUTHOR_FEEDBACK_CRITERION = (
"Every point in the CEO rejection feedback is visibly addressed in the rendered cut"
)
_CHAT_TIMEOUT_SECONDS = 60.0
# The whole CHANGELOG section for a release, not one bullet — capped so a
# pathological entry can't blow up the task description.
_CHANGELOG_BRIEF_CHARS = 4000
# ``open_video_task``'s "no open task for this occasion yet" check and its
# insert are NOT atomic on their own: unlike the other engines' `run_cycle`
# (each driven by exactly one sequential orchestrator-loop task, so it can
# never overlap itself), this method is reachable from several genuinely
# concurrent callers — the release-publish hook, the feature-spotlight hook,
# and the CEO's on-demand ``POST /video/request`` route (two overlapping
# requests, e.g. a double-click, each get their own DB session/transaction).
# A flat SET NX (mirrors XPostService's identical short-critical-section
# lock) keyed by occasion closes that window instead of letting a duplicate
# authoring task slip through.
_OCCASION_LOCK_PREFIX = "roboco:video_engine:occasion:"
_OCCASION_LOCK_TTL_SECONDS = 60 # the check+insert completes in ms; crash backstop
# Shared by every open_video_task caller (release/spotlight/on-demand) so the
# authoring dev always lands on the demo kit instead of a text card.
_MOTION_DESIGN_POINTER = (
"Before authoring: read motion/README.md's design bar and motion/kit/"
"README.md. Build in the panel-demo register on motion/kit/ — extend "
"compositions/panel-demo/ rather than starting from scratch or shipping "
"a text card. Before submitting: call request_render and read every "
"returned frame to verify the RENDERED cut, not just the source."
"a text card."
)
@@ -143,99 +114,36 @@ def _first_changelog_bullet(changelog: str) -> str:
return highlights[0] if highlights else ""
def _fallback_release_script(version: str, changelog: str, product_name: str) -> str:
def _fallback_release_script(version: str, changelog: str) -> str:
highlight = _first_changelog_bullet(changelog)
lead = f": {highlight}" if highlight else ""
return f"{product_name} v{version} just shipped{lead}."
return f"RoboCo v{version} just shipped{lead}."
def _release_video_prompt(version: str, changelog: str, product_name: str) -> str:
def _release_video_prompt(version: str, changelog: str) -> str:
return (
f"You are {product_name}'s marketing team, writing a short voiceover "
"script for a bespoke motion-graphics video announcing a release. "
"Plain text, 2-3 short sentences, energetic but factual — no "
"invented facts.\n\n"
f"Write the script for {product_name} v{version}, based on this "
f"CHANGELOG entry:\n{changelog[:1000]}\n"
"You are RoboCo's marketing team, writing a short voiceover script "
"for a bespoke motion-graphics video announcing a release. Plain "
"text, 2-3 short sentences, energetic but factual — no invented "
"facts.\n\n"
f"Write the script for RoboCo v{version}, based on this CHANGELOG "
f"entry:\n{changelog[:1000]}\n"
)
def _release_video_brief(
version: str, changelog: str, highlights: list[str], product_name: str
) -> str:
def _release_video_brief(version: str, changelog: str, highlights: list[str]) -> str:
"""The structured release brief: the (capped) CHANGELOG section for this
version plus its highlights list replaces the old one-liner-as-
description. The LLM script stays a separate ``script`` prop suggestion,
never the whole brief."""
section = changelog[:_CHANGELOG_BRIEF_CHARS].strip() or "(no changelog entry)"
parts = [f"{product_name} v{version} release notes:", section]
parts = [f"RoboCo v{version} release notes:", section]
if highlights:
bullets = "\n".join(f"- {h}" for h in highlights)
parts.append(f"Highlights:\n{bullets}")
return "\n\n".join(parts)
def _scene_criterion(features: list[str]) -> str | None:
"""Turn an enumerable brief feature list into its own gate-checkable AC.
The live failure this closes: a brief named N features, the task's ACs
stayed generic, a dev shipped fewer scenes, and every gate passed because
"N features" existed only in prose. None for an empty/absent list. The
joined list is bounded to the AC per-item char cap truncated with an
"… (+N more)" tail rather than overrunning it.
"""
trimmed = [f.strip() for f in features if f and f.strip()]
if not trimmed:
return None
prefix = "Every brief-named feature appears as its own fully readable scene: "
budget = _AC_MAX_ITEM_CHARS - len(prefix)
kept = list(trimmed)
while kept:
omitted = len(trimmed) - len(kept)
tail = f"… (+{omitted} more)" if omitted else ""
body = "; ".join(kept) + tail
if len(body) <= budget:
return prefix + body
kept.pop()
return prefix + "" # pathological: even one (huge) feature name overruns
def _authoring_criteria(
suggested_input_props: dict[str, Any] | None,
fallback_acceptance_criterion: str | None,
) -> list[str]:
"""The base authoring ACs plus one derived/fallback criterion: the scene
criterion when ``suggested_input_props["highlights"]`` is a real list,
else ``fallback_acceptance_criterion`` (when given), else nothing."""
criteria = list(_AUTHORING_ACCEPTANCE_CRITERIA)
features = (suggested_input_props or {}).get("highlights")
criterion = _scene_criterion(features if isinstance(features, list) else [])
criterion = criterion or fallback_acceptance_criterion
if criterion is not None and len(criteria) < _AC_MAX_ITEMS:
criteria.append(criterion)
return criteria
def _reauthor_brief(reason: str, draft: dict[str, Any]) -> str:
"""The revision brief for a CEO-rejected cut: the verbatim rejection
feedback, a revise-in-place pointer at the existing composition (when
known), then the original brief/script for context."""
parts = [
"REVISION of a CEO-rejected cut. CEO rejection feedback "
f"(address every point): {reason}"
]
composition_id = draft.get("composition_id")
if composition_id:
parts.append(
f"Revise the EXISTING composition motion/compositions/{composition_id}/ "
"in place — do not start a new composition."
)
original = draft.get("brief") or draft.get("script") or ""
if original:
parts.append(original)
return "\n\n".join(parts)
class VideoEngine(BaseService):
"""Open video-authoring tasks (event hooks + on-demand), both gated."""
@@ -340,7 +248,6 @@ class VideoEngine(BaseService):
brief: str,
suggested_input_props: dict[str, Any] | None = None,
project_id: UUID | None = None,
fallback_acceptance_criterion: str | None = None,
) -> TaskTable | None:
"""Originate ONE UX/UI authoring task for a bespoke video, or None.
@@ -363,73 +270,9 @@ class VideoEngine(BaseService):
"highlights": [...]}`` from the release caller) is seeded onto the
marker as-is so the dev copies real structured data into
``propose_video``'s ``input_props`` instead of hand-typing facts.
When ``suggested_input_props`` carries a ``highlights`` list, it
becomes its OWN acceptance criterion (``_scene_criterion``) a
prose-only feature list used to pass gates even when a dev shipped
fewer scenes than named. Absent highlights,
``fallback_acceptance_criterion`` (when supplied) is appended instead
``reauthor_from_rejection`` uses this for its
feedback-must-be-addressed criterion.
The dedup check + insert below run under a short-lived Redis mutex
keyed by ``occasion`` (see ``_OCCASION_LOCK_PREFIX``) this method,
unlike the other engines' single-loop ``run_cycle``, is reachable
from several genuinely concurrent callers (the release/spotlight
hooks and the on-demand ``/video/request`` route), so the "no open
task yet" read and the create must be atomic against each other.
Fails closed: a Redis outage or a lock already held both no-op
(return None) rather than risk a duplicate authoring task.
"""
if not settings.video_engine_enabled:
return None
lock = HeartbeatMutex(
f"{_OCCASION_LOCK_PREFIX}{occasion}",
ttl_seconds=_OCCASION_LOCK_TTL_SECONDS,
heartbeat_seconds=_OCCASION_LOCK_TTL_SECONDS,
)
try:
token = await lock.acquire()
except HeartbeatLockUnavailable as exc:
self.log.warning(
"video-engine: occasion lock unavailable (redis down); "
"not opening authoring task",
occasion=occasion,
error=str(exc),
)
return None
if token is None:
self.log.info(
"video-engine: another call is already opening this occasion; skipping",
occasion=occasion,
)
return None
try:
return await self._open_video_task_locked(
occasion=occasion,
script=script,
platforms=platforms,
brief=brief,
suggested_input_props=suggested_input_props,
project_id=project_id,
fallback_acceptance_criterion=fallback_acceptance_criterion,
)
finally:
await lock.release(token)
async def _open_video_task_locked(
self,
*,
occasion: str,
script: str,
platforms: list[str],
brief: str,
suggested_input_props: dict[str, Any] | None,
project_id: UUID | None,
fallback_acceptance_criterion: str | None,
) -> TaskTable | None:
"""The dedup-check + insert body, run while ``open_video_task`` holds
the per-occasion lock."""
task_svc = get_task_service(self.session)
open_tasks = await task_svc.list_open_video_posts()
for existing in open_tasks:
@@ -451,9 +294,6 @@ class VideoEngine(BaseService):
assignee = self._select_ux_dev(open_tasks)
enriched_brief = await self._enrich_brief(brief)
acceptance_criteria = _authoring_criteria(
suggested_input_props, fallback_acceptance_criterion
)
# Savepoint-isolate the insert: a DBAPI error here (FK, deadlock,
# dropped connection) must roll back ONLY this insert, never poison the
# shared session — whose next commit is the caller's release-publish
@@ -464,7 +304,7 @@ class VideoEngine(BaseService):
TaskCreateRequest(
title=f"Video: {occasion}",
description=enriched_brief,
acceptance_criteria=acceptance_criteria,
acceptance_criteria=list(_AUTHORING_ACCEPTANCE_CRITERIA),
team=Team.UX_UI,
assigned_to=assignee,
created_by=_foundation.AGENTS["system"].uuid,
@@ -509,7 +349,7 @@ class VideoEngine(BaseService):
# ---- release trigger (event-driven hook) -------------------------------
async def draft_release_video(
self, *, version: str, changelog: str, project_id: UUID | None = None
self, *, version: str, changelog: str
) -> TaskTable | None:
"""Originate ONE UX/UI video-authoring task for a release announcement,
or None (no-op).
@@ -527,40 +367,27 @@ class VideoEngine(BaseService):
"""
if not (settings.video_engine_enabled and settings.video_on_release):
return None
project = (
await get_project_service(self.session).get(project_id)
if project_id is not None
else None
)
product_name = await get_company_goals_service(
self.session
).resolve_product_name(project)
script = await self._draft_release_script(version, changelog, product_name)
script = await self._draft_release_script(version, changelog)
highlights = _changelog_highlights(changelog)
brief = _release_video_brief(version, changelog, highlights, product_name)
brief = _release_video_brief(version, changelog, highlights)
return await self.open_video_task(
occasion=f"release {version}",
script=script,
platforms=["x", "tiktok"],
brief=brief,
suggested_input_props={"version": version, "highlights": highlights},
project_id=project_id,
)
async def _draft_release_script(
self, version: str, changelog: str, product_name: str
) -> str:
async def _draft_release_script(self, version: str, changelog: str) -> str:
try:
draft = await _chat(_release_video_prompt(version, changelog, product_name))
draft = await _chat(_release_video_prompt(version, changelog))
except Exception as exc:
self.log.warning(
"video-engine: local-model script draft failed (fallback template)",
error=str(exc),
)
draft = None
return (draft or "").strip() or _fallback_release_script(
version, changelog, product_name
)
return (draft or "").strip() or _fallback_release_script(version, changelog)
# ---- held draft (materialized once a render pass produces MP4s) -------
@@ -620,93 +447,8 @@ class VideoEngine(BaseService):
"video-engine: video post drafted (held for CEO)",
source_task_id=str(source_task.id),
)
try:
await get_notification_delivery_service(
self.session
).notify_ceo_of_queue_item(
kind="video", id8=str(task.id)[:8], title=occasion
)
except Exception as exc:
self.log.warning(
"video-engine: telegram notify failed (best-effort)", error=str(exc)
)
return task
# ---- reject -> re-author (CEO feedback loop) ---------------------------
async def _resolve_reauthor_project(
self, post_task: TaskTable, draft: dict[str, Any]
) -> UUID | None:
"""The project to re-author against: the rejected post's own
``project_id`` (the normal case), else its source authoring task's
(via the draft's ``source_task_id``) — a defensive fallback for a
draft that somehow landed without one."""
if post_task.project_id is not None:
return cast("UUID", post_task.project_id)
source_task_id = draft.get("source_task_id")
if not source_task_id:
return None
source_task = await get_task_service(self.session).get(
cast("UUID", source_task_id)
)
return cast("UUID", source_task.project_id) if source_task else None
async def reauthor_from_rejection(
self, post_task: TaskTable, reason: str
) -> TaskTable | None:
"""Route a CEO's rejection reason into a fresh authoring task that
revises the SAME composition in place, instead of the feedback going
nowhere.
Reads the rejected ``video_post`` draft's carried-forward
``video_draft`` marker (occasion, brief/script, composition_id,
platforms, input_props, source_task_id) and re-opens via
``open_video_task`` under the SAME occasion that call's own dedup
only scans OPEN drafts, so the just-cancelled post never blocks it (a
second reject while a revision is already open correctly dedups
against it instead of stacking a third).
Best-effort: never raises. A missing draft marker, an unresolvable
project, or any other failure just logs a warning and returns None
the caller's reject must succeed regardless of this seam.
"""
try:
draft = markers.get_video_draft(post_task)
if draft is None:
self.log.warning(
"video-engine: reauthor skipped, no video_draft marker",
task_id=str(post_task.id),
)
return None
project_id = await self._resolve_reauthor_project(post_task, draft)
if project_id is None:
self.log.warning(
"video-engine: reauthor skipped, no project resolvable",
task_id=str(post_task.id),
)
return None
return await self.open_video_task(
occasion=str(draft.get("occasion") or post_task.title),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
brief=_reauthor_brief(reason, draft),
suggested_input_props=(
draft.get("input_props") or draft.get("suggested_input_props")
),
project_id=project_id,
# Original had highlights -> the scene criterion regenerates
# from them; no highlights -> this fallback names the reason
# (already verbatim in the brief) as the checkable outcome.
fallback_acceptance_criterion=_REAUTHOR_FEEDBACK_CRITERION,
)
except Exception as exc:
self.log.warning(
"video-engine: reauthor from rejection failed",
task_id=str(post_task.id),
error=str(exc),
)
return None
# ---- re-render (CEO-triggered retry) -----------------------------------
async def rerender(self, task_id: UUID) -> TaskTable | None:
@@ -744,3 +486,16 @@ class VideoEngine(BaseService):
def get_video_engine(session: AsyncSession) -> VideoEngine:
"""Build a VideoEngine for ``session``."""
return VideoEngine(session)
def resolve_preview_path(root: Path, file_path: str) -> Path | None:
"""Resolve ``file_path`` against the workspace ``root``, refusing
anything that escapes it. A leading ``/`` is stripped before joining
pathlib's ``/`` operator otherwise lets an absolute right operand
discard ``root`` entirely then the joined path must resolve to an
existing file still under ``root``. The sole confinement check for the
CEO preview proxy."""
candidate = (root / file_path.lstrip("/")).resolve()
if not candidate.is_relative_to(root) or not candidate.is_file():
return None
return candidate
+57 -83
View File
@@ -30,6 +30,7 @@ from roboco.services.task import VIDEO_POST_SOURCE, get_task_service
from roboco.services.x_client import MAX_TWEET_CHARS
if TYPE_CHECKING:
from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
@@ -150,10 +151,10 @@ class VideoPostExecuteResult:
"""The outcome of an approve call.
`status` is one of: posted, posted_partial, post_failed, no_platforms,
already_posted, already_rejected, already_in_progress, redis_unavailable,
lock_lost. `posted` maps platform -> the id the poster returned, for
platforms that succeeded (persisted, so a retry after a partial failure
never re-posts an already-succeeded platform).
already_posted, already_in_progress, redis_unavailable, lock_lost.
`posted` maps platform -> the id the poster returned, for platforms that
succeeded (persisted, so a retry after a partial failure never re-posts
an already-succeeded platform).
"""
status: str
@@ -218,12 +219,6 @@ class VideoPostService(BaseService):
if task.status == TaskStatus.COMPLETED:
draft = dict(markers.get_video_draft(task) or {})
return self._already_posted_result(draft)
if task.status == TaskStatus.CANCELLED:
return VideoPostExecuteResult(
status="already_rejected",
posted={},
detail="this draft was already rejected",
)
mutex = HeartbeatMutex(
f"{_LOCK_PREFIX}{task_id}",
@@ -283,12 +278,6 @@ class VideoPostService(BaseService):
draft = dict(markers.get_video_draft(locked) or {})
if locked.status == TaskStatus.COMPLETED:
return self._already_posted_result(draft)
if locked.status == TaskStatus.CANCELLED:
return VideoPostExecuteResult(
status="already_rejected",
posted={},
detail="this draft was already rejected",
)
guarded = await mutex.run_guarded(
self._post_all_platforms(locked, draft, validated_captions), token
)
@@ -323,8 +312,7 @@ class VideoPostService(BaseService):
cancellation, or a crash can never lose the record of what already
posted; a retry re-reads the committed draft and skips it via the
`already_posted` check below. Only commits COMPLETED once every
CONFIGURED platform has posted (`_finalize_post`) a platform with
no credentials is skipped, never a pending-forever failure.
platform has posted (`_finalize_post`).
Each commit runs through `_commit_shielded` a lock-loss
cancellation firing while it's in flight must not interrupt it (see
@@ -338,19 +326,11 @@ class VideoPostService(BaseService):
)
posted: dict[str, str] = {}
failures: dict[str, str] = {}
skipped: dict[str, str] = {}
for platform in platforms:
already_posted = draft.get(f"{platform}_posted_id")
if already_posted:
posted[platform] = str(already_posted)
continue
# An UNCONFIGURED platform is a standing deployment fact, not a
# transient failure: treating it as a failure left every draft
# targeting it pending forever (retry semantics that can never
# succeed), parking an already-X-posted card in the CEO queue.
if not self._platform_configured(platform):
skipped[platform] = "no credentials configured"
continue
posted_id, detail = await self._attempt_platform_post(platform, draft)
if posted_id is None:
failures[platform] = detail
@@ -369,17 +349,7 @@ class VideoPostService(BaseService):
# platform-native idempotency key is a future follow-up.
markers.set_video_draft(task, dict(draft))
await self._commit_shielded()
return await self._finalize_post(task, posted, failures, skipped)
def _platform_configured(self, platform: str) -> bool:
"""Whether `platform`'s poster holds credentials. Unknown platforms
report True so they still fall through to the explicit
unknown-platform failure in `_post_platform`."""
if platform == "x":
return bool(self._x_poster.configured)
if platform == "tiktok":
return bool(self._tiktok_poster.configured)
return True
return await self._finalize_post(task, posted, failures)
async def _commit_shielded(self) -> None:
"""Commit via asyncio.shield so a lock-loss cancellation firing
@@ -452,34 +422,17 @@ class VideoPostService(BaseService):
return result.publish_id, result.detail
async def _finalize_post(
self,
task: TaskTable,
posted: dict[str, str],
failures: dict[str, str],
skipped: dict[str, str],
self, task: TaskTable, posted: dict[str, str], failures: dict[str, str]
) -> VideoPostExecuteResult:
if not failures and posted:
if not failures:
task.status = TaskStatus.COMPLETED
# Commit while still holding the lock so COMPLETED is durable
# before release — otherwise a racing approve could acquire the
# lock the instant we drop it and double-post before a
# route-level commit. Shielded — see _commit_shielded.
await self._commit_shielded()
detail = "posted to all configured platforms"
if skipped:
detail += "; skipped (unconfigured): " + ", ".join(sorted(skipped))
return VideoPostExecuteResult(
status="posted", posted=dict(posted), detail=detail
)
if not failures and skipped:
# Nothing posted and nothing failed — every target platform is
# unconfigured. Refuse loudly instead of completing a draft that
# never reached any audience.
detail = "no target platform has credentials configured: " + ", ".join(
sorted(skipped)
)
return VideoPostExecuteResult(
status="post_failed", posted={}, detail=detail
status="posted", posted=dict(posted), detail="posted to all platforms"
)
# Every successful platform's posted-id was already committed in the
# loop above (see _post_all_platforms) — nothing left to persist.
@@ -536,9 +489,7 @@ class VideoPostService(BaseService):
)
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
"""Record the CEO's reason, cancel the draft (never posted), and
route the feedback into a fresh authoring task that revises the same
composition.
"""Record the CEO's reason and cancel the draft (never posted).
Acquires the same post-mutex ``approve()`` holds (same key, same
non-blocking acquire style) so a reject can't interleave with a
@@ -584,31 +535,9 @@ class VideoPostService(BaseService):
markers.set_video_reject_reason(locked, reason)
locked.status = TaskStatus.CANCELLED
await self.session.flush()
cancelled = locked
return locked
finally:
await mutex.release(token)
# Outside the lock/try-finally, after the cancel is committed/flushed:
# a reauthor failure must never fail or roll back the reject above.
if reason.strip():
await self._reauthor_after_reject(cancelled, reason)
return cancelled
async def _reauthor_after_reject(self, task: TaskTable, reason: str) -> None:
"""Best-effort: hand the CEO's reject reason to VideoEngine so it
opens a revision authoring task. Never raises."""
# Local import: no cycle (video_engine doesn't import this module),
# but mirrors the lazy get_video_engine import every other caller
# (release_proposal.py, x_post_service.py) uses.
from roboco.services.video_engine import get_video_engine
try:
await get_video_engine(self.session).reauthor_from_rejection(task, reason)
except Exception as exc:
logger.warning(
"video-post reauthor-from-rejection failed for task %s: %s",
task.id,
exc,
)
def get_video_post_service(
@@ -627,3 +556,48 @@ def get_video_post_service(
x_poster=x_poster or NullXVideoPoster(),
tiktok_poster=tiktok_poster or NullTikTokPoster(),
)
def resolve_video_cut(task: TaskTable, cut: str) -> Path:
"""Resolve the on-disk MP4 path for ``cut`` off the task's held draft, or
404. The ``is_relative_to`` confinement check stays even though the MinIO
key is a basename (traversal-proof) it also guards the ``FileResponse``
fallback path that reads ``mp4_path`` straight from disk."""
from pathlib import Path
from fastapi import HTTPException, status
from roboco.config import settings
draft = markers.get_video_draft(task) or {}
mp4_path = (draft.get("mp4_paths") or {}).get(cut)
if not mp4_path or not Path(mp4_path).is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut"
)
output_dir = Path(settings.video_output_dir).resolve()
if not Path(mp4_path).resolve().is_relative_to(output_dir):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut"
)
return Path(mp4_path)
async def build_real_video_post_service(db: AsyncSession) -> VideoPostService:
"""A VideoPostService wired with the real posters, built from stored
credentials. Only ``approve`` needs live posters list/reject never
call one, so they use the inert Null defaults (``get_video_post_service
(db)``) instead."""
from roboco.config import settings
from roboco.services.tiktok_client import build_tiktok_poster
from roboco.services.tiktok_credentials import get_tiktok_credentials_service
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_video_client import build_x_video_poster
x_creds = await get_x_credentials_service(db).get_decrypted()
x_poster = build_x_video_poster(x_creds, timeout=settings.x_request_timeout_seconds)
tiktok_creds = await get_tiktok_credentials_service(db).get_decrypted()
tiktok_poster = build_tiktok_poster(
tiktok_creds, session=db, timeout=settings.video_request_timeout_seconds
)
return get_video_post_service(db, x_poster=x_poster, tiktok_poster=tiktok_poster)
@@ -13,8 +13,7 @@ import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import _ServiceHolder, set_orchestrator
from roboco.api.routes.orchestrator import _validated_agent_id
from roboco.api.deps import _ServiceHolder, set_orchestrator, validate_agent_id_param
from roboco.api.routes.orchestrator import router as orch_router
if TYPE_CHECKING:
@@ -46,13 +45,13 @@ def test_validated_agent_id_rejects_path_traversal(bad: str) -> None:
# agent_id is a request path param that flows into per-agent filesystem
# paths; a traversal vector must be rejected at the HTTP boundary with 422.
with pytest.raises(HTTPException) as exc:
_validated_agent_id(bad)
validate_agent_id_param(bad)
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
def test_validated_agent_id_accepts_real_slugs() -> None:
for slug in ("be-dev-1", "pr-reviewer-1", "main-pm", "intake", "secretary"):
assert _validated_agent_id(slug) == slug
assert validate_agent_id_param(slug) == slug
# ---------------------------------------------------------------------------
+8 -116
View File
@@ -15,14 +15,12 @@ from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.tasks import (
_translate_error,
get_awaiting_ceo_approval_tasks,
get_awaiting_pm_review_tasks,
)
from roboco.api.routes.tasks import (
router as tasks_router,
)
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable, TaskTable, WorkSessionTable
from roboco.exceptions import GitError, TaskLifecycleError
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
@@ -44,7 +42,7 @@ from roboco.services.base import ServiceError as SvcError
from roboco.services.git import GitService
from roboco.services.notification_delivery import EscalationError
from roboco.services.permissions import PermissionService
from roboco.services.task import TaskService
from roboco.services.task import TaskService, translate_task_error
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -279,35 +277,6 @@ async def test_get_task_by_id(task_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_get_task_by_id_includes_spend_when_budgets_enabled(
task_client: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""spend_usd is populated (0.0 with no spawn sessions yet) once
ROBOCO_TASK_BUDGETS_ENABLED is on the extra DB read only runs then."""
monkeypatch.setattr(settings, "task_budgets_enabled", True)
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.get(f"/api/tasks/{task.id}", headers=_HDR)
assert response.status_code == HTTPStatus.OK
assert response.json()["spend_usd"] == 0.0
@pytest.mark.asyncio
async def test_get_task_by_id_omits_spend_when_budgets_disabled(
task_client: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flag off => spend_usd stays null, the same as before this field existed."""
monkeypatch.setattr(settings, "task_budgets_enabled", False)
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.get(f"/api/tasks/{task.id}", headers=_HDR)
assert response.status_code == HTTPStatus.OK
assert response.json()["spend_usd"] is None
@pytest.mark.asyncio
async def test_update_task(task_client: dict) -> None:
client = task_client["client"]
@@ -321,52 +290,6 @@ async def test_update_task(task_client: dict) -> None:
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
@pytest.mark.asyncio
async def test_update_task_rejects_zero_budget_usd(task_client: dict) -> None:
"""#654: a 0 cap would block every claim immediately — rejected at the
request boundary, never stored."""
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.patch(
f"/api/tasks/{task.id}",
json={"budget_usd": 0},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_task_rejects_negative_budget_usd(task_client: dict) -> None:
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
response = await client.patch(
f"/api/tasks/{task.id}",
json={"budget_usd": -5},
headers=_HDR,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_task_accepts_positive_budget_usd(task_client: dict) -> None:
# budget_usd is a _PRIVILEGED_UPDATE_FIELDS / non-"PM lighter" field —
# a plain main_pm PATCH would 403 here, so exercise the CEO's full scope.
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client)
await task_client["db"].flush()
budget = 12.5
response = await client.patch(
f"/api/tasks/{task.id}",
json={"budget_usd": budget},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["budget_usd"] == budget
@pytest.mark.asyncio
async def test_update_task_status_override_recovers_blocked(task_client: dict) -> None:
"""A privileged PATCH with ``status`` + ``force`` is applied as an audited
@@ -1813,14 +1736,14 @@ async def test_cancel_task_pm_succeeds(task_client: dict) -> None:
# ---------------------------------------------------------------------------
# _translate_error: direct unit coverage for service-error → HTTP mapping
# translate_task_error: direct unit coverage for service-error → HTTP mapping
# ---------------------------------------------------------------------------
def test_translate_error_not_found() -> None:
"""NotFoundError → 404."""
err = NotFoundError(resource_type="task", resource_id="123")
http_exc = _translate_error(err)
http_exc = translate_task_error(err)
assert isinstance(http_exc, HTTPException)
assert http_exc.status_code == HTTPStatus.NOT_FOUND
assert "task not found" in http_exc.detail.lower()
@@ -1829,7 +1752,7 @@ def test_translate_error_not_found() -> None:
def test_translate_error_unauthorized() -> None:
"""UnauthorizedError → 403."""
err = UnauthorizedError(action="delete", reason="not your task")
http_exc = _translate_error(err)
http_exc = translate_task_error(err)
assert http_exc.status_code == HTTPStatus.FORBIDDEN
assert "delete" in http_exc.detail
@@ -1837,7 +1760,7 @@ def test_translate_error_unauthorized() -> None:
def test_translate_error_validation() -> None:
"""ValidationError → 400."""
err = ValidationError("bad field value")
http_exc = _translate_error(err)
http_exc = translate_task_error(err)
assert http_exc.status_code == HTTPStatus.BAD_REQUEST
assert http_exc.detail == "bad field value"
@@ -1845,7 +1768,7 @@ def test_translate_error_validation() -> None:
def test_translate_error_generic_service_error() -> None:
"""Plain ServiceError → 500."""
err = ServiceError("service exploded")
http_exc = _translate_error(err)
http_exc = translate_task_error(err)
assert http_exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
assert http_exc.detail == "service exploded"
@@ -1938,13 +1861,13 @@ async def test_update_task_service_returns_none_yields_500(
# ---------------------------------------------------------------------------
# claim_task: ServiceError -> _translate_error
# claim_task: ServiceError -> translate_task_error
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_task_service_error_translated(task_client: dict) -> None:
"""A ServiceError raised by claim_task_for_agent surfaces via _translate_error."""
"""A ServiceError from claim_task_for_agent surfaces via translate_task_error."""
task = _seed_task(task_client)
await task_client["db"].flush()
@@ -2163,37 +2086,6 @@ async def test_resume_task_success(task_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_pause_task_ceo_success(task_client: dict) -> None:
"""The CEO can pause a task assigned to someone else through the plain
pause route (a non-assignee, non-CEO caller still gets 403
``test_pause_task_forbidden`` covers that unchanged)."""
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS, assigned_to=other.id)
await task_client["db"].flush()
_as_ceo(task_client)
response = await task_client["client"].post(
f"/api/tasks/{task.id}/pause", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "paused"
@pytest.mark.asyncio
async def test_resume_task_ceo_success(task_client: dict) -> None:
"""The CEO can resume a task assigned to someone else through the plain
resume route same carve-out as pause above."""
other = await _seed_agent(task_client)
task = _seed_task(task_client, status=TaskStatus.PAUSED, assigned_to=other.id)
await task_client["db"].flush()
_as_ceo(task_client)
response = await task_client["client"].post(
f"/api/tasks/{task.id}/resume", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] != "paused"
@pytest.mark.asyncio
async def test_verify_task_success(task_client: dict) -> None:
task = _seed_task(
+20 -241
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
@@ -29,6 +29,7 @@ from roboco.services import minio_client
from roboco.services.heartbeat_mutex import HeartbeatMutex
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from roboco.services.tiktok_credentials import get_tiktok_credentials_service
from roboco.services.video_engine import resolve_preview_path
from roboco.services.video_post_service import XVideoPostResult
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_video_client import LiveXVideoPoster
@@ -46,7 +47,6 @@ UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
HISTORY_LIMIT = 2
RETRY_ATTEMPTS = 2
PREVIEW_DURATION_SECONDS = 6.0
async def _seed(session: AsyncSession) -> None:
@@ -265,16 +265,15 @@ async def test_request_video_opens_authoring_task(
project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one()
with _LOCKED[0], _LOCKED[1]:
resp = await ceo_client.post(
"/api/video/request",
json={
"occasion": "CEO on-demand: launch teaser",
"brief": "A short teaser for the new dashboard",
"platforms": ["x", "tiktok"],
"project_id": str(project.id),
},
)
resp = await ceo_client.post(
"/api/video/request",
json={
"occasion": "CEO on-demand: launch teaser",
"brief": "A short teaser for the new dashboard",
"platforms": ["x", "tiktok"],
"project_id": str(project.id),
},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "opened"
@@ -397,14 +396,12 @@ async def test_request_video_not_opened_on_duplicate_occasion(
"platforms": ["x"],
"project_id": str(project.id),
}
with _LOCKED[0], _LOCKED[1]:
first = await ceo_client.post("/api/video/request", json=payload)
first = await ceo_client.post("/api/video/request", json=payload)
assert first.status_code == HTTPStatus.OK
assert first.json()["status"] == "opened"
task_id = first.json()["task_id"]
try:
with _LOCKED[0], _LOCKED[1]:
second = await ceo_client.post("/api/video/request", json=payload)
second = await ceo_client.post("/api/video/request", json=payload)
assert second.status_code == HTTPStatus.OK
assert second.json()["status"] == "not_opened"
assert second.json()["task_id"] is None
@@ -418,7 +415,6 @@ async def test_list_posts_returns_open_draft(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
project = await db_session.get(ProjectTable, task.project_id)
resp = await ceo_client.get("/api/video/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
@@ -430,9 +426,6 @@ async def test_list_posts_returns_open_draft(
"square": "/render/out/1-square.mp4",
"vertical": "/render/out/1-vertical.mp4",
}
assert project is not None
assert body[0]["project_slug"] == project.slug
assert body[0]["project_name"] == project.name
@pytest.mark.asyncio
@@ -479,7 +472,6 @@ async def test_pipeline_lists_non_terminal_authoring_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
project = await db_session.get(ProjectTable, task.project_id)
resp = await ceo_client.get("/api/video/pipeline")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
@@ -490,9 +482,6 @@ async def test_pipeline_lists_non_terminal_authoring_task(
assert row["render_attempts"] == 0
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
assert row["render_error"] is None
assert project is not None
assert row["project_slug"] == project.slug
assert row["project_name"] == project.name
@pytest.mark.asyncio
@@ -718,7 +707,6 @@ async def test_history_returns_posted_and_rejected_newest_first(
json={"reason": "wrong occasion"},
)
posted = await _seed_draft(db_session, platforms=["x"])
posted_project = await db_session.get(ProjectTable, posted.project_id)
creds_svc = get_x_credentials_service(db_session)
await creds_svc.set_credentials(
api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
@@ -749,9 +737,6 @@ async def test_history_returns_posted_and_rejected_newest_first(
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
assert posted_row["status"] == "completed"
assert posted_row["posted"] == {"x": "xid42"}
assert posted_project is not None
assert posted_row["project_slug"] == posted_project.slug
assert posted_row["project_name"] == posted_project.name
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
assert rejected_row["status"] == "cancelled"
assert rejected_row["reject_reason"] == "wrong occasion"
@@ -973,7 +958,7 @@ async def test_media_serves_from_minio_when_configured(
) -> None:
"""Configured serve path: when MinIO is configured, the media route streams
the object via ``minio_client.get_object_stream`` (key = basename) and the
panel-preview URL/headers stay identical. ``_require_ceo`` still 403s a
panel-preview URL/headers stay identical. ``require_ceo_role`` still 403s a
non-CEO agent. No DB / no real MinIO ``get_task_service`` is stubbed so
the route runs without postgres."""
# A real local file so the route's is_file() + confinement checks pass.
@@ -1001,7 +986,7 @@ async def test_media_serves_from_minio_when_configured(
assert resp.content == b"minio-stream-bytes"
app.dependency_overrides.clear()
# Non-CEO 403 — _require_ceo still gates end-to-end (no presigned URL).
# Non-CEO 403 — require_ceo_role still gates end-to-end (no presigned URL).
app = _build_app(None, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@@ -1168,9 +1153,7 @@ def test_resolve_preview_path_serves_file_inside_root(tmp_path: Path) -> None:
(root / "motion" / "compositions" / "Intro").mkdir(parents=True)
target = root / "motion" / "compositions" / "Intro" / "vertical.html"
target.write_text("<html></html>")
resolved = video_module._resolve_preview_path(
root, "motion/compositions/Intro/vertical.html"
)
resolved = resolve_preview_path(root, "motion/compositions/Intro/vertical.html")
assert resolved == target.resolve()
@@ -1179,8 +1162,8 @@ def test_resolve_preview_path_blocks_dot_dot_traversal(tmp_path: Path) -> None:
(root / "motion").mkdir(parents=True)
secret = tmp_path / "secret.txt"
secret.write_text("nope")
assert video_module._resolve_preview_path(root, "../secret.txt") is None
assert video_module._resolve_preview_path(root, "motion/../../secret.txt") is None
assert resolve_preview_path(root, "../secret.txt") is None
assert resolve_preview_path(root, "motion/../../secret.txt") is None
def test_resolve_preview_path_blocks_absolute_path_override(tmp_path: Path) -> None:
@@ -1191,13 +1174,13 @@ def test_resolve_preview_path_blocks_absolute_path_override(tmp_path: Path) -> N
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("nope")
assert video_module._resolve_preview_path(root, str(outside)) is None
assert resolve_preview_path(root, str(outside)) is None
def test_resolve_preview_path_missing_file_is_none(tmp_path: Path) -> None:
root = (tmp_path / "clone").resolve()
root.mkdir()
assert video_module._resolve_preview_path(root, "motion/nope.html") is None
assert resolve_preview_path(root, "motion/nope.html") is None
@pytest.mark.asyncio
@@ -1288,207 +1271,3 @@ async def test_preview_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
resp = await client.get(f"/api/video/preview/{task.id}/vertical.html")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
# --- preview frames (CEO-facing request_render surface) -----------------------
def _write_preview_frame(
root: Path, orientation: str, idx: int, count: int, timestamp: float
) -> Path:
"""One request_render-shaped frame file — filename encodes index/count/
timestamp exactly as video-renderer/render.js writes it."""
d = root / orientation
d.mkdir(parents=True, exist_ok=True)
path = d / f"frame-{idx:02d}-of-{count}-at-{timestamp:.1f}s.png"
path.write_bytes(b"fake-png-bytes")
return path
def _previews_dir(workspaces_root: Path, project_slug: str, task_id: UUID) -> Path:
return workspaces_root / project_slug / ".previews" / task_id.hex[:8]
@pytest.mark.asyncio
async def test_preview_frames_lists_both_orientations_with_marker_metadata(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(
db_session,
status=TaskStatus.IN_PROGRESS,
draft_extra={"composition_id": "Intro"},
)
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
root = _previews_dir(tmp_path, project.slug, cast("UUID", task.id))
_write_preview_frame(root, "vertical", 1, 2, 1.5)
_write_preview_frame(root, "vertical", 2, 2, 4.5)
_write_preview_frame(root, "square", 1, 1, 3.0)
markers.set_render_preview(
task,
{
"at": "2026-07-20T00:00:00+00:00",
"composition_id": "Intro",
"orientation": "square",
"frame_count": 1,
"duration_seconds": PREVIEW_DURATION_SECONDS,
"frames": [],
"head_sha": "abc123",
"dirty": False,
},
)
await db_session.flush()
resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["composition_id"] == "Intro"
assert body["duration_seconds"] == PREVIEW_DURATION_SECONDS
assert body["head_sha"] == "abc123"
assert body["dirty"] is False
assert body["rendered_at"] == "2026-07-20T00:00:00+00:00"
vertical = body["frames"]["vertical"]
assert [f["index"] for f in vertical] == [1, 2]
assert [f["timestamp_seconds"] for f in vertical] == [1.5, 4.5]
assert body["frames"]["square"][0]["file"].startswith("frame-01-of-1-at-3.0s")
@pytest.mark.asyncio
async def test_preview_frames_no_render_yet_is_404(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A source=video task with no request_render call yet has nothing under
.previews/ 404, not an empty 200 the panel would render as a blank
section."""
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frames_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get(f"/api/video/preview-frames/{uuid4()}")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frames_non_video_task_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session) # source=video_post, not video
resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frames_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_preview_frame_streams_png_bytes(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
root = _previews_dir(tmp_path, project.slug, cast("UUID", task.id))
frame_path = _write_preview_frame(root, "vertical", 1, 1, 0.5)
resp = await ceo_client.get(
f"/api/video/preview-frames/{task.id}/vertical/{frame_path.name}"
)
assert resp.status_code == HTTPStatus.OK
assert resp.headers["content-type"] == "image/png"
assert resp.content == b"fake-png-bytes"
@pytest.mark.asyncio
async def test_preview_frame_bad_orientation_is_400(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get(
f"/api/video/preview-frames/{task.id}/diagonal/frame-01-of-1-at-0.5s.png"
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_preview_frame_missing_file_is_404(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get(
f"/api/video/preview-frames/{task.id}/vertical/frame-01-of-1-at-0.5s.png"
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frame_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
f"/api/video/preview-frames/{task.id}/vertical/frame-01-of-1-at-0.5s.png"
)
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
def test_previews_root_confines_frame_orientation_traversal_through_symlink(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Drives the REAL ``_previews_root`` -> ``_resolve_preview_path`` chain
(the preview-frame route composes ``f"{orientation}/{filename}"`` before
calling ``_resolve_preview_path``) with an UNRESOLVED, symlinked
``workspaces_root`` the container-mount shape. Before ``_previews_root``
resolved its own path, the ``is_relative_to`` confinement check compared a
resolved candidate against an unresolved root and 404'd every legit
frame; this proves a real frame under the symlink still serves while
traversal is still rejected."""
real_root = tmp_path / "real-workspaces"
real_root.mkdir()
symlinked_root = tmp_path / "workspaces-symlink"
symlinked_root.symlink_to(real_root)
monkeypatch.setattr(cfg, "workspaces_root", str(symlinked_root))
task_id = uuid4()
project_slug = "roboco-x"
frame_dir = real_root / project_slug / ".previews" / task_id.hex[:8] / "vertical"
frame_dir.mkdir(parents=True)
frame = frame_dir / "frame-01-of-1-at-0.5s.png"
frame.write_bytes(b"png")
secret = tmp_path / "secret.png"
secret.write_bytes(b"nope")
root = video_module._previews_root(project_slug, task_id)
assert (
video_module._resolve_preview_path(root, "vertical/frame-01-of-1-at-0.5s.png")
== frame.resolve()
)
assert video_module._resolve_preview_path(root, "vertical/../../secret.png") is None
assert video_module._resolve_preview_path(root, "../secret.png") is None
@@ -11,10 +11,14 @@ from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from roboco.api.routes.video import (
_to_history_response,
_to_pipeline_item,
_to_response,
from roboco.api.schemas.video import (
task_to_pipeline_item as _to_pipeline_item,
)
from roboco.api.schemas.video import (
task_to_video_post_history_response as _to_history_response,
)
from roboco.api.schemas.video import (
task_to_video_post_response as _to_response,
)
+2 -2
View File
@@ -262,7 +262,7 @@ async def test_cloud_auth_valid_session_cookie_passes(
client, orch = orch_client
fake_user = MagicMock()
with patch(
"roboco.api.routes.orchestrator.resolve_session_user",
"roboco.api.deps.resolve_session_user",
new=AsyncMock(return_value=fake_user),
):
r = await client.post(
@@ -288,7 +288,7 @@ async def test_cloud_auth_invalid_session_cookie_rejected(
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
client, orch = orch_client
with patch(
"roboco.api.routes.orchestrator.resolve_session_user",
"roboco.api.deps.resolve_session_user",
new=AsyncMock(return_value=None),
):
r = await client.post(
@@ -19,20 +19,18 @@ from uuid import uuid4
import pytest
import pytest_asyncio
import roboco.api.routes.orchestrator as orch_route
from fastapi import FastAPI, HTTPException
import roboco.services.task as task_service_module
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.agents_config import AGENT_UUIDS
from roboco.api.deps import _ServiceHolder, set_orchestrator
from roboco.api.routes.orchestrator import (
_build_manual_spawn_prompt,
_resolve_manual_spawn_prompt,
_validated_agent_id,
)
from roboco.api.routes.orchestrator import (
router as orch_router,
)
from roboco.runtime.orchestrator import AgentReadinessError, AgentState
from roboco.services.task import (
build_manual_spawn_prompt,
resolve_manual_spawn_prompt,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -75,12 +73,12 @@ class _FakeTaskService:
# ---------------------------------------------------------------------------
# _build_manual_spawn_prompt — pure formatting
# build_manual_spawn_prompt — pure formatting
# ---------------------------------------------------------------------------
def test_build_manual_spawn_prompt_includes_task_fields() -> None:
prompt = _build_manual_spawn_prompt(_fake_task("awaiting_qa"), None)
prompt = build_manual_spawn_prompt(_fake_task("awaiting_qa"), None)
assert "TASK ID: task-123" in prompt
assert "TITLE: Fix the thing" in prompt
assert "STATUS: awaiting_qa" in prompt
@@ -89,7 +87,7 @@ def test_build_manual_spawn_prompt_includes_task_fields() -> None:
def test_build_manual_spawn_prompt_appends_ceo_note() -> None:
prompt = _build_manual_spawn_prompt(_fake_task(), "Please prioritize this.")
prompt = build_manual_spawn_prompt(_fake_task(), "Please prioritize this.")
assert "== CEO NOTE ==" in prompt
assert "Please prioritize this." in prompt
# CEO note comes after the task framing, not instead of it.
@@ -97,13 +95,13 @@ def test_build_manual_spawn_prompt_appends_ceo_note() -> None:
# ---------------------------------------------------------------------------
# _resolve_manual_spawn_prompt — best-effort enrichment
# resolve_manual_spawn_prompt — best-effort enrichment
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_prompt_no_task_id_returns_message_unchanged() -> None:
result = await _resolve_manual_spawn_prompt(None, "hello")
result = await resolve_manual_spawn_prompt(None, "hello")
assert result == "hello"
@@ -111,13 +109,13 @@ async def test_resolve_prompt_no_task_id_returns_message_unchanged() -> None:
async def test_resolve_prompt_enriches_when_task_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(orch_route, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(task_service_module, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(
orch_route,
task_service_module,
"get_task_service",
lambda _db: _FakeTaskService(task=_fake_task("verifying")),
)
result = await _resolve_manual_spawn_prompt(str(uuid4()), "Ship it")
result = await resolve_manual_spawn_prompt(str(uuid4()), "Ship it")
assert result is not None
assert "STATUS: verifying" in result
assert "Ship it" in result
@@ -127,18 +125,18 @@ async def test_resolve_prompt_enriches_when_task_found(
async def test_resolve_prompt_falls_back_when_task_not_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(orch_route, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(task_service_module, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(
orch_route, "get_task_service", lambda _db: _FakeTaskService(task=None)
task_service_module, "get_task_service", lambda _db: _FakeTaskService(task=None)
)
result = await _resolve_manual_spawn_prompt(str(uuid4()), "hello")
result = await resolve_manual_spawn_prompt(str(uuid4()), "hello")
assert result == "hello"
@pytest.mark.asyncio
async def test_resolve_prompt_falls_back_on_bad_task_id() -> None:
# Not a valid UUID — must not raise, must fall back unchanged.
result = await _resolve_manual_spawn_prompt("not-a-uuid", "hello")
result = await resolve_manual_spawn_prompt("not-a-uuid", "hello")
assert result == "hello"
@@ -146,19 +144,19 @@ async def test_resolve_prompt_falls_back_on_bad_task_id() -> None:
async def test_resolve_prompt_falls_back_on_db_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(orch_route, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(task_service_module, "get_db_context", _FakeDbCtx)
monkeypatch.setattr(
orch_route,
task_service_module,
"get_task_service",
lambda _db: _FakeTaskService(error=RuntimeError("db down")),
)
result = await _resolve_manual_spawn_prompt(str(uuid4()), "hello")
result = await resolve_manual_spawn_prompt(str(uuid4()), "hello")
assert result == "hello"
@pytest.mark.asyncio
async def test_resolve_prompt_no_message_no_task_returns_none() -> None:
result = await _resolve_manual_spawn_prompt(None, None)
result = await resolve_manual_spawn_prompt(None, None)
assert result is None
@@ -277,76 +275,3 @@ async def test_spawn_offline_agent_not_flagged_already_running(
)
assert response.status_code == HTTPStatus.CREATED
assert response.json()["already_running"] is False
# ---------------------------------------------------------------------------
# _validated_agent_id — UUID -> slug normalization (root fix: a caller that
# addresses a runtime container/instance by an agent's DB UUID instead of its
# slug, e.g. the panel spawn button, must resolve to the same canonical slug
# the orchestrator's instance registry and container names use).
# ---------------------------------------------------------------------------
def test_validated_agent_id_resolves_known_uuid_to_slug() -> None:
uuid_str = AGENT_UUIDS["head-marketing"]
assert _validated_agent_id(uuid_str) == "head-marketing"
def test_validated_agent_id_passes_through_slug_unchanged() -> None:
assert _validated_agent_id("head-marketing") == "head-marketing"
def test_validated_agent_id_passes_through_unknown_uuid_unchanged() -> None:
# A uuid4 is never a seeded agent UUID (the seeds are deterministic,
# low-cardinality values) — genuinely absent from the UUID -> slug map.
unknown_uuid = str(uuid4())
assert unknown_uuid not in AGENT_UUIDS.values()
assert _validated_agent_id(unknown_uuid) == unknown_uuid
def test_validated_agent_id_still_rejects_traversal() -> None:
with pytest.raises(HTTPException) as exc_info:
_validated_agent_id("../etc/passwd")
assert exc_info.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_spawn_by_uuid_reaches_orchestrator_by_slug(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
"""The panel (or any caller) posting the agent's DB UUID as the path
param must not produce a container/instance keyed by that UUID the
orchestrator only ever sees the canonical slug."""
client, orch = orch_client
orch.get_instance = MagicMock(return_value=None)
instance = SimpleNamespace(
id=uuid4(),
agent_id="head-marketing",
state=AgentState.STARTING,
current_task_id=None,
error_count=0,
started_at=datetime.now(UTC),
)
orch.spawn_agent = AsyncMock(return_value=instance)
uuid_str = AGENT_UUIDS["head-marketing"]
response = await client.post(
f"/api/orchestrator/agents/{uuid_str}/spawn", headers=_HDR
)
assert response.status_code == HTTPStatus.CREATED
orch.spawn_agent.assert_awaited_once()
assert orch.spawn_agent.await_args.kwargs["agent_id"] == "head-marketing"
@pytest.mark.asyncio
async def test_stop_by_uuid_reaches_orchestrator_by_slug(
orch_client: tuple[AsyncClient, MagicMock],
) -> None:
client, orch = orch_client
orch.stop_agent = AsyncMock(return_value=None)
uuid_str = AGENT_UUIDS["be-dev-1"]
response = await client.post(
f"/api/orchestrator/agents/{uuid_str}/stop", headers=_HDR
)
assert response.status_code == HTTPStatus.NO_CONTENT
orch.stop_agent.assert_awaited_once()
assert orch.stop_agent.await_args.args[0] == "be-dev-1"
+3 -3
View File
@@ -11,7 +11,7 @@ from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
from roboco.api.routes.tasks import _apply_null_clears
from roboco.services.task import apply_null_clears
def _task(**overrides: object) -> SimpleNamespace:
@@ -31,7 +31,7 @@ def _task(**overrides: object) -> SimpleNamespace:
def test_unassign_clears_claim_fields() -> None:
"""assigned_to=null releases the claim triplet with it."""
task = _task()
_apply_null_clears(task, {"assigned_to": None})
apply_null_clears(task, {"assigned_to": None})
assert task.assigned_to is None
assert task.claimed_by is None
assert task.claimed_at is None
@@ -42,7 +42,7 @@ def test_other_null_clears_leave_claim_untouched() -> None:
"""Clearing parent_task_id/project_id is structural — not a claim release."""
owner = uuid4()
task = _task(assigned_to=owner, claimed_by=owner, active_claimant_id=owner)
_apply_null_clears(task, {"parent_task_id": None})
apply_null_clears(task, {"parent_task_id": None})
assert task.parent_task_id is None
assert task.assigned_to == owner
assert task.claimed_by == owner
@@ -0,0 +1,50 @@
"""Regression guard for Batch A of the route-helper cleanup.
``roboco/api/routes/tasks.py``, ``a2a.py``, ``orchestrator.py``, ``video.py``,
``journals.py``, ``v1/_role_dep.py``, ``roadmap.py`` and ``prompter_live.py``
were audited against the real conventions validator (not a crude top-level
scan) and every module-level definition in them is already a proper
``@router``/``@app`` route handler (or, for ``v1/_role_dep.py``, not a
function definition at all) there is nothing to relocate. This test pins
that fact so a future top-level helper slipping into one of these files is
caught by the gate instead of silently reintroducing the violation.
"""
from __future__ import annotations
from pathlib import Path
from roboco.conventions.runner import run
from roboco.conventions.scan import derive_from_scan
from roboco.foundation.policy.conventions.effective_map import effective_map
from roboco.foundation.policy.conventions.models import ConventionsStandard
_REPO_ROOT = Path(__file__).resolve().parents[3]
_BATCH_A_FILES = [
"roboco/api/routes/tasks.py",
"roboco/api/routes/a2a.py",
"roboco/api/routes/orchestrator.py",
"roboco/api/routes/video.py",
"roboco/api/routes/journals.py",
"roboco/api/routes/v1/_role_dep.py",
"roboco/api/routes/roadmap.py",
"roboco/api/routes/prompter_live.py",
]
def _effective_standard() -> ConventionsStandard:
derived = derive_from_scan(_REPO_ROOT)
committed_path = _REPO_ROOT / ".roboco" / "conventions.yml"
committed = (
ConventionsStandard.parse_yaml(committed_path.read_text())
if committed_path.is_file()
else None
)
return effective_map(derived, committed)
def test_batch_a_route_files_have_no_helper_placement_findings() -> None:
standard = _effective_standard()
findings = run(_REPO_ROOT, _BATCH_A_FILES, standard)
helper_findings = [f for f in findings if f.kind == "helper"]
assert helper_findings == []