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