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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user