mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
co-authored by
Backend Developer 1
Backend Documenter
parent
666f261a1a
commit
109b4d4d82
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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_id→project 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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+33
-278
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user