Gateway/full (#9)

* chore(gateway): scaffold gateway package and test layout

* feat(config): add gateway feature flags, coordination thresholds, commit-validator settings

* feat(gateway): add standardized response envelope with ok/error variants

* feat(gateway): add remediation hint catalog for tracing-gap and invalid-state errors

* feat(gateway): add per-role flow/do tool catalog with developer, qa, doc, pm, board configs

* feat(db): add gateway columns — active_claimant_id, heartbeat, pre_block snapshot, acceptance_criteria_status, qa_evidence_inspected

* feat(db): create gateway_triggers table for dispatcher decision logging

* feat(db): align canonical skill set; substitute qa_review -> code_review across agent seeds

* fix(db/008): make skill alignment in-place + idempotent; preserve column and existing custom skills

* feat(gateway): add claimant_lock for single-active-agent invariant with heartbeat staleness

* feat(gateway): add trigger_filter with stale-cleanup, claimant-queue, and cooldown rules

* feat(gateway): add tracing_gate with plan, progress, journal, acceptance_criteria, qa requirements

* refactor(gateway): drop per-file ruff ignores; refactor tracing_gate with dispatch table + GateContext

* feat(gateway): add merge_chain to resolve PR target by branch hierarchy depth

* feat(gateway): add commit_validator with min-length, banned-words, and conventional-shape hints

* feat(gateway): add evidence_builder for verb-response evidence and capped context_briefing

* feat(gateway): add Choreographer skeleton with per-phase verb signatures and DI protocols

* feat(runtime): add spawn_manifest builder for per-role pre-loaded tool registration

Introduces SpawnInputs dataclass + build_for_role(inputs) + write_manifest()
in roboco/runtime/spawn_manifest.py; reads role_config for allowed verbs/tools,
emits JSON manifest that SDK shim reads at container startup to eliminate ToolSearch.

* feat(runtime): wire gateway pre-spawn check (trigger_filter + claimant_lock) into orchestrator behind ROBOCO_GATEWAY_ENABLED flag

- Add GatewayTriggerTable SQLAlchemy ORM model to roboco/db/tables.py
  (matches existing table from migration 007_gateway_triggers_table)
- Add module-level gateway_pre_spawn_check() + helpers to orchestrator.py
  (gated: returns ("spawn", "gateway disabled") immediately when flag is False)
- Wire gateway check into _safe_spawn() — the single dispatcher choke-point
  for all agent spawns; QUEUE or DROP outcome logs and returns None (no spawn)
- ROBOCO_GATEWAY_ENABLED defaults to False; legacy behaviour is unchanged

* feat(agent_sdk): load tool-manifest.json at startup behind ROBOCO_GATEWAY_ENABLED flag (no agent-visible change yet)

Adds load_tool_manifest() to the SDK server that reads env at call-time
so gateway-enabled agents can obtain their pre-registered tool list at
startup; returns None when the flag is off, leaving the legacy briefing
path completely unchanged.

* fix(optimal_brain): skip indexing when source ID is None to eliminate roboco://journals/None spam

- Add `build_doc_source(kind, id_)` module-level helper in indexes/base.py that
  returns None when id_ is None instead of producing a "roboco://journals/None" URI
- Update abstract `build_source_uri` return type to `str | None` so subclasses
  can legitimately signal a missing ID
- Short-circuit `ingest()` and `_prepare_docs_for_batch()` in BaseIndexPlugin
  when `build_source_uri` returns None (debug log, no push to vector store)
- Fix JournalsIndexPlugin.build_source_uri: `kwargs.get("entry_id")` returns the
  kwarg value even when it is None, so fall back to doc_id before calling
  build_doc_source
- Fix ConversationsIndexPlugin.build_source_uri: return None when session_id is
  None rather than producing "roboco://conversations/None-unknown"
- Add 9 unit tests with a piragi-free conftest that stubs sys.modules

* fix(agent_sdk): inject X-Agent-ID header on notification-poller requests

Both `_check_pending_a2a` and `_auto_ack_a2a_notifications` in
`roboco/mcp/a2a_server.py` were calling the main API without identity
headers, causing orchestrator `Missing X-Agent-ID header` warnings on
`GET /api/v1/notifications/pending-a2a` and the ack-a2a POST.

Add module-level `AGENT_ROLE` constant (mirrors the existing `AGENT_ID`
pattern) and pass `{"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE}`
on both requests.

* fix(git): use ROBOCO_PUBLIC_BASE_URL for commit-trailer Links instead of hardcoded localhost

* fix(test_runner): call uv run pytest/ruff directly; add make to orchestrator Dockerfile as backstop

FileNotFoundError was propagating as a raw 500 when a project had `make test`
configured but make was not installed in the orchestrator container.

Two fixes:
1. Catch FileNotFoundError in _run_command and re-raise as ValidationError (400)
   with a clear message telling the operator to reconfigure the project command
   (e.g. replace 'make test' with 'uv run pytest').
2. Add `make` to the orchestrator Dockerfile runner-stage apt-get so projects
   that legitimately use make targets continue to work without reconfiguration.

* fix(api/git): resolve project by slug or UUID in git_log endpoint

Add _resolve_project_slug() helper to git routes that tries UUID
lookup first and falls back to slug, matching the pattern already
used in project routes. Apply to all four read-only git endpoints:
status, log, branches, diff.

* fix(a2a): auto-create conversation when conversation_id absent; reject empty IDs in URL builder

* fix(agent_sdk): default subagent model to parent agent's model from spawn manifest, not hardcoded haiku

Inject CLAUDE_CODE_SUBAGENT_MODEL env var into every agent container at
spawn time.  Claude Code ≥2.1.x reads this variable to override the
default Task (Agent) subagent model, which otherwise hard-codes
claude-haiku-4-5-20251001.  When the parent runs on a non-Anthropic
provider (e.g. Ollama Cloud / minimax-m2.7:cloud) that Anthropic model
is unreachable, so subagent dispatch fails.

The value follows the same provider-aware translation already used for
the --model CLI flag: Anthropic short names go through MODEL_MAP, and
non-Anthropic identifiers are passed verbatim.  To avoid calling the
class by name inside a @staticmethod, the shared translation logic is
extracted to the module-level _resolve_agent_cli_model() helper;
_resolve_cli_model() now delegates to it.

Verified: CLAUDE_CODE_SUBAGENT_MODEL is present and honoured in the
Claude Code 2.1.123 binary (grep confirmed the env-var lookup pattern
`if(process.env.CLAUDE_CODE_SUBAGENT_MODEL) return KK(…)`).

* chore(makefile): add quality and quality-fast targets composing every PR gate

* chore(quality): add import-linter dependency and gateway boundary contract

* test(property): scaffold tracing-completeness assertion (filled in Phase 4)

* Format test file to pass ruff check

* fix(gateway): drop Protocol scaffolding from choreographer skeleton; del-statements on unused stub args; clear vulture whitelist

* linting

* feat(gateway): Phase 1 dev cutover — ChoreographerDeps + give_me_work

Add ChoreographerDeps frozen dataclass (7 deps: task, work_session, git,
a2a, journal, audit, evidence_repo), refactor Choreographer.__init__ to
accept the bundle, implement give_me_work + _briefing_for via
evidence_builder.build_context_briefing, and add property accessors for
all deps. All Phase 2-4 stubs gain del-statements and still raise
NotImplementedError. 3 tests added and passing; mypy/ruff/vulture clean.

* feat(gateway): implement i_will_work_on handling pending, claimed, and needs_revision recovery

* feat(gateway): implement i_have_committed with plan-required precondition

Replaces the NotImplementedError stub with the real implementation: looks up
the agent's active task, enforces plan presence before recording, calls
task.add_progress, and returns a structured Envelope. Adds 3 unit tests
(records progress, no active task → invalid_state, no plan → tracing_gap).

* feat(gateway): implement i_am_done with smart catch-up and skill resolution

* feat(gateway): implement i_am_blocked (struggle + escalate) and i_am_idle (with unread soft-block)

* feat(gateway): add ContentActions for commit, note, say, dm, evidence with auto-inject and validation

* feat(api/v2): add /api/v2/flow/dev/* endpoints delegating to Choreographer

Six intent-verb endpoints (give_me_work, i_will_work_on, i_have_committed,
i_am_done, i_am_blocked, i_am_idle) under /api/v2/flow/dev/, each a thin
handler that delegates to Choreographer. Includes Pydantic request schemas,
EvidenceRepo Phase 1 stub (all methods return []), get_choreographer FastAPI
dep wired with all 7 service deps, and 8 unit tests (all passing).

* feat(api/v2): add /api/v2/do/* endpoints for commit, note, say, dm, evidence

* feat(mcp): add roboco-flow MCP server for intent verbs (Phase 1: dev verbs implemented)

* feat(mcp): add roboco-do MCP server for smart-wrapped content tools

* feat(runtime): mount per-agent tool-manifest.json on developer-container spawn; gateway flag enabled for devs only

* docs(prompts): rewrite developer role prompt for gateway-only verbs (~15 lines vs 49)

* chore(mcp): confirm dev manifest excludes legacy task/journal/notify/a2a tools (Phase 1 cutover; servers retired in Phase 4)

* feat(gateway): implement claim_review with inline evidence (kills #15) and qa_evidence_inspected tracking

* feat(gateway): implement pass_review with qa_notes/learning/evidence tracing gates

* feat(gateway): implement fail_review with issue list, tracing gates, and dev A2A handoff

* feat(api/v2): add /api/v2/flow/qa/* endpoints (claim_review, pass, fail, give_me_work, i_am_idle)

* feat(mcp): add QA verbs (claim_review, pass, fail) to roboco-flow MCP server

* docs(prompts): rewrite QA role prompt for gateway verbs; explicitly warn against grep-the-commit anti-pattern

* feat(runtime): enable gateway flag for QA-role spawns (Phase 2 cutover)

* feat(gateway): implement claim_doc_task and i_documented with file-list and notes-min-chars gates

* feat(gateway): implement triage (cell PM) and triage_all (main PM) with priority order

* feat(gateway): implement unblock with pre_block_state restoration (kills #23)

* feat(gateway): implement cell_pm_complete with auto-merge to parent branch (kills #22 for cell scope)

* feat(gateway): implement main_pm_complete (open master PR + escalate to CEO)

* feat(gateway): add complete() dispatcher routing to cell_pm_complete or main_pm_complete by role

* feat(gateway): implement escalate_up routing by role.escalation_target

* feat(api/v2): add /api/v2/flow/{documenter,cell_pm,main_pm}/* endpoints

* feat(mcp): add Doc + PM verbs to roboco-flow MCP server (claim_doc_task, i_documented, triage, triage_all, unblock, complete, escalate_up)

* docs(prompts): rewrite Doc, Cell PM, and Main PM role prompts for gateway verbs

* feat(runtime): enable gateway flag for Doc, Cell PM, and Main PM roles (Phase 3 cutover)

* test(integration): full pending->awaiting_ceo_approval test through dev/QA/doc/cell-PM/main-PM gateway path

* chore(tests): rename unused args to _args in flow_server tests

Cleared RUF059 lint blocker for Phase 3 closeout. The destructured args was only consumed in URL-asserting tests; one variant only checks kwargs["json"], so its args is now _args.

* chore: untrack docs/superpowers/ + add to .gitignore

Plans + spec were inadvertently swept into commits 5d41a4b and de0c5b5 by subagent 'git add -A' calls. Removed from index and gitignored going forward; files remain on disk for ongoing reference. They still exist in history of those two commits — invoke a follow-up filter-repo if a full purge is desired.

* feat(gateway): implement Board escalate_to_ceo with role allow-list

Allows main_pm, product_owner, and head_marketing to escalate tasks to
CEO. Enforces awaiting_pm_review state and journal:decision tracing gate.
Closes Phase 4 Task 1.

* feat(gateway): implement board_triage prioritizing strategic root tasks

Adds Choreographer.board_triage and TaskService.list_strategic_for_board.
PO and Head Marketing get curated lists of strategic-nature root tasks
in awaiting_pm_review. Closes Phase 4 Task 2.

* feat(gateway): implement auditor_triage surfacing long-running blocked-task anomalies

Adds Choreographer.auditor_triage and TaskService.list_long_running_blocked.
The Auditor surfaces tasks blocked >30min as anomalies for reflect-note
observation. Closes Phase 4 Task 3.

* chore(tests): add return + arg type annotations to gateway tests

All gateway test functions now have -> None and parameter annotations. Cleared 63 mypy [no-untyped-def] errors that pre-existed since Phase 1. Mypy now clean across tests/unit/gateway/.

* feat(api/v2): add /api/v2/flow/{board,auditor}/* endpoints

Board: triage, escalate_to_ceo, i_am_idle.
Auditor: triage, i_am_idle (read-only role).
Adds EscalateToCeoRequest schema with reason min_length validation.
Closes Phase 4 Task 4.

* feat(mcp): add Board + Auditor verbs to roboco-flow MCP server

Adds escalate_to_ceo MCP tool used by Board (PO + Head Marketing) and Main PM. Updates the implemented set in _validate_role_compatibility. Auditor uses the existing triage tool with role-routing in URL.

Closes Phase 4 Task 5.

* docs(prompts): rewrite Board (PO, Head-Marketing, Auditor) prompts for gateway verbs

All 3 board identity files + roles/board.md now use the slim, gateway-aware shape (no ToolSearch directive, no state-tool table). Auditor is explicit about its read-only scope. Closes Phase 4 Task 6.

* feat(runtime): enable gateway manifest for ALL roles (Phase 4 cutover)

Adds product_owner, head_marketing, auditor to GATEWAY_ENABLED_ROLES. Every spawned agent now gets a gateway manifest mounted at /app/tool-manifest.json. The legacy briefing path is dead. Closes Phase 4 Task 8.

* test(property): implement tracing-completeness assertion across smoke-test batch

Replaces Phase 0 stub. Asserts the 6 tracing-contract requirements on every
completed task: audit_log agent_id non-null per state-transition row,
DEVELOPER:TASK_REFLECTION journal entry, QA:LEARNING journal entry,
CELL_PM/MAIN_PM:DECISION_LOG journal entry, acceptance_criteria_status
covering every criterion with a referencing_artifact_id, and
qa_evidence_inspected = true.

Uses an in-memory ephemeral Postgres test DB (`roboco_test_<pid>_<rand>`)
provisioned per pytest session, not SQLite — the production schema relies
on Postgres-only types (UUID, ARRAY) the SQLite dialect cannot compile.
Tests requesting db_session/smoke_test_batch are auto-skipped when no
Postgres is reachable on localhost:5432; ROBOCO_TEST_DB_HOST/PORT/USER
override the endpoint.

Schema is built via Base.metadata.create_all + manual ALTER for the
acceptance_criteria_status / qa_evidence_inspected columns, NOT via
`alembic upgrade head`. This sidesteps two pre-existing layer-drift items
that block any fresh migration run today:

  1. Migration 001 declares the agentrole Postgres enum with lowercase
     values (qa, developer, ...) but the SQLAlchemy ORM binds
     Enum(AgentRole) to the StrEnum's uppercase NAMES — production DBs
     mask this by being bootstrapped via create_all and stamped at 001.
  2. Migration 008 runs UPDATE agents SET skills WHERE id over an
     agents.skills column that no migration in this chain ever creates.

Documented in conftest.py so a future migrations cleanup can find them.
Also notes that acceptance_criteria_status/qa_evidence_inspected are in
the DB schema (per migration 006) but are NOT mapped on the ORM TaskTable
nor on the Pydantic Task model — services that read them via
`task.qa_evidence_inspected` rely on those values being set on raw rows.
The property test uses raw SQL to read the columns directly, matching the
DB-level contract.

Closes Phase 4 Task 11.

Side change: pyproject.toml — adds asyncpg.* to the existing
[[tool.mypy.overrides]] ignore_missing_imports list (asyncpg ships no
py.typed marker), matching the convention used for redis, anthropic,
piragi, etc.

Test count: 1; backend: Postgres (localhost test DB).

* style(mcp/flow_server): single-line _post call after format pass

* fix(db): map 7 gateway columns from migration 006 to TaskTable + Task model

active_claimant_id, last_heartbeat_at, pre_block_state, pre_block_assignee, pre_block_metadata, acceptance_criteria_status, qa_evidence_inspected: present in DB since migration 006 but absent from the ORM mapping. Gateway code (tracing_gate, choreographer, claimant_lock) reads these via task.<attr>; without the mapping, runtime would AttributeError. Closes PHASE4-BUG-A.

* fix(db): repair alembic chain — neutralize 008, add 009 enum reconcile, ORM uses values_callable

Three coordinated changes that close PHASE4-BUG-B:

1. roboco/db/tables.py — introduce _str_enum() helper that wraps Enum() with values_callable=lambda obj: [m.value for m in obj]. Apply to all 23 StrEnum-typed mapped columns. ORM now serializes by .value (lowercase) to match alembic 001's declared enum values; default Enum() was using .name (uppercase) which never matched.

2. alembic/versions/008_align_skills.py — replace with documented no-op. The original migration referenced agents.skills, a column that has never existed in any migration (the agents table has capabilities, not skills). The substitution intent (qa_review -> code_review) was already satisfied statically in roboco/agents_config.py.

3. alembic/versions/009_enum_reconcile.py — new migration that:
   - Adds missing enum values: agentrole.system, team.fullstack, taskstatus.quarantined.
   - Detects uppercase drift from a Base.metadata.create_all bootstrap and rebuilds agentrole/team/taskstatus enums with lowercase members + USING lower(col::text)::enum on every column referenced. No-op if already lowercase.

Tests stay green: 281 passed.

* feat(services): backfill 36 gateway-shaped methods for Choreographer

The gateway Choreographer was wired to call methods that the underlying
services did not expose. This adds them as thin wrappers + queries (most
alias canonical methods; a handful are gateway-specific variants).

TaskService — 26 methods: aliases (submit_verification, submit_qa,
list_blocked_for_team, list_blocked_all_teams,
list_awaiting_pm_review_for_team, list_assigned_for_agent), agent
queries (agent_for, qa_agent_for_team, documenter_for_team,
cell_pm_for_team, get_active_task_for_agent, list_paused_for_agent),
triage queries (list_awaiting_main_pm_all, all_subtasks_terminal),
state setters (set_plan, mark_evidence_inspected, mark_agent_idle),
QA/Doc claim variants (qa_claim, doc_claim, qa_pass, qa_fail),
PM completion (cell_pm_complete with merge_commit), unblock with
state restore (unblock_with_restore), and escalation
(escalate, escalate_up_to_role). Also adds GatewayAgentView
dataclass that unifies DB and config-derived agent attributes.

JournalService — 4 methods: existence checks (has_decision_for_task,
has_learning_for_task, has_reflect_for_task) + write_struggle.

GitService — 4 methods: branch-keyed entry points (create_pr,
pr_merge, pr_target, diff) plus push_branch helper. Each derives
project + workspace from the task that owns the branch / PR.

WorkSessionService — 2 methods: files_changed + has_unpushed_commits.
PR existence is the proxy for pushed (no per-commit push column).

Choreographer: switched git.push(branch_name) call to push_branch()
to dispatch to the new gateway-shaped helper.

* test(services): unit tests for 36 gateway-backfill methods

Adds happy-path + edge tests for every method added in the prior
backfill commit. Total 61 new tests across:
  - tests/unit/services/test_task.py (36)
  - tests/unit/services/test_journal.py (8)
  - tests/unit/services/test_git.py (10)
  - tests/unit/services/test_work_session.py (7)

Each test mocks at the session boundary (no DB) and stubs adjacent
service methods via a dynamic _bind helper to avoid mypy
[method-assign] noise without resorting to type:ignore comments.

* test(gateway): switch dev catch-up assertion to push_branch

The Choreographer's catch-up sequence was renamed from git.push(branch)
to git.push_branch(branch) when GitService got a gateway-shaped helper
in the prior commit. This updates the existing assertion to match.

* feat(mcp): add roboco-git-readonly server with status/log/diff/branches

Slim FastMCP server exposing the four read-only git tools every role
needs (status, log, diff, branch_list) by forwarding to /api/v1/git/*
on the orchestrator. Replaces the read-only half of the legacy
roboco-git server; write operations now go through gateway verbs in
roboco-flow / roboco-do.

The endpoint shapes mirror the panel-facing API (project_slug,
include_remote, staged/file_path) so the same backend handlers serve
both human and agent traffic.

* refactor(mcp): delete legacy task/journal/notify/a2a/message/project servers

Phase 4 cutover: agents now reach every state-changing surface through
the gateway (roboco-flow intent verbs + roboco-do content tools), with
roboco-git-readonly + roboco-optimal + roboco-docs covering reads. The
seven legacy MCP servers + their handler trees are dead code from the
agent side, so they're removed:

  roboco/mcp/task_server.py            (1020 LOC)
  roboco/mcp/journal_server.py         (512 LOC)
  roboco/mcp/notify_server.py          (440 LOC)
  roboco/mcp/a2a_server.py             (790 LOC)
  roboco/mcp/message_server.py         (682 LOC)
  roboco/mcp/project_server.py         (667 LOC)
  roboco/mcp/tasks/  (handlers+utils)  (~4300 LOC)
  roboco/mcp/test/   (in-container runner; replaced by gateway evidence
                      + manual smoke)
  roboco/mcp/git/    (full server; read-only half migrates to the new
                      slim roboco-git-readonly module, write half is
                      owned by gateway verbs)

Orchestrator updates:
  - _generate_mcp_config registers only roboco-flow, roboco-do,
    roboco-git-readonly, roboco-optimal, and (for docs roles) roboco-docs.
    No more per-role legacy fan-out.
  - base_allow flips to mcp__roboco-flow__*, mcp__roboco-do__*,
    mcp__roboco-optimal__*, mcp__roboco-git-readonly__*. Role-specific
    allow lists are reduced to file IO scoping, since gateway verbs
    enforce role policy server-side.
  - TRACEABILITY_TRIGGER_TOOLS rewritten in terms of the gateway servers
    (mcp__roboco-flow__* / mcp__roboco-do__*) instead of the now-deleted
    per-tool list.

Test fix: tests/unit/services/test_a2a.py imported _handle_send_chat_message
from the deleted a2a_server. The four MCP-layer URL-builder tests (empty
conversation_id guard) are dropped — the equivalent boundary now lives
in /api/v2/do/* which has its own integration coverage. The two
service-layer nil-UUID guard tests are kept; they exercise A2AService
directly and remain meaningful (the panel still uses the v1 chat surface,
where a buggy caller could pass the nil UUID).

Net: ~9600 LOC removed from roboco/mcp/. quality-fast green:
338 tests pass, mypy clean, ruff clean. No /api/v1/* router changes —
those endpoints stay live for the panel UI which still uses every
lifecycle action; agents have no prompts that name them so the path is
dead code from the agent side.

* docs(claude.md): replace legacy MCP listing with gateway/verb-surface section

Phase 4 cutover: agents go through roboco-flow + roboco-do (gateway), not the deleted task/journal/notify/a2a/message/project servers. Document the verb surface per role + the Envelope response shape so future Claude Code sessions land in the correct mental model. Closes Phase 4 Task 13.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-05-02 03:11:49 +02:00
committed by GitHub
co-authored by Renn F
parent 254cc93fd5
commit 62bda0c497
150 changed files with 13516 additions and 10588 deletions
View File
View File
@@ -0,0 +1,84 @@
"""Unit tests: _resolve_project_slug accepts slug or UUID."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes.git import _resolve_project_slug
_HTTP_404 = 404
def _make_project(slug: str, uid: UUID) -> MagicMock:
"""Return a minimal project-like object."""
project = MagicMock()
project.slug = slug
project.id = uid
return project
@pytest.mark.asyncio
async def test_resolve_project_slug_accepts_slug() -> None:
"""A plain slug string resolves to the project's slug."""
project = _make_project("roboco", uuid4())
mock_service = MagicMock()
mock_service.get_by_slug = AsyncMock(return_value=project)
with patch("roboco.api.routes.git.get_project_service", return_value=mock_service):
result = await _resolve_project_slug("roboco", MagicMock())
assert result == "roboco"
mock_service.get_by_slug.assert_awaited_once_with("roboco")
mock_service.get.assert_not_called()
@pytest.mark.asyncio
async def test_resolve_project_slug_accepts_uuid() -> None:
"""A UUID string resolves to the project's slug."""
uid = uuid4()
project = _make_project("roboco", uid)
mock_service = MagicMock()
mock_service.get = AsyncMock(return_value=project)
with patch("roboco.api.routes.git.get_project_service", return_value=mock_service):
result = await _resolve_project_slug(str(uid), MagicMock())
assert result == "roboco"
mock_service.get.assert_awaited_once_with(UUID(str(uid)))
mock_service.get_by_slug.assert_not_called()
@pytest.mark.asyncio
async def test_resolve_project_slug_raises_404_for_missing_slug() -> None:
"""Unknown slug raises HTTPException 404."""
mock_service = MagicMock()
mock_service.get_by_slug = AsyncMock(return_value=None)
with (
patch("roboco.api.routes.git.get_project_service", return_value=mock_service),
pytest.raises(HTTPException) as exc_info,
):
await _resolve_project_slug("nonexistent", MagicMock())
assert exc_info.value.status_code == _HTTP_404
assert "nonexistent" in exc_info.value.detail
@pytest.mark.asyncio
async def test_resolve_project_slug_raises_404_for_missing_uuid() -> None:
"""UUID that matches no project raises HTTPException 404."""
uid = uuid4()
mock_service = MagicMock()
mock_service.get = AsyncMock(return_value=None)
with (
patch("roboco.api.routes.git.get_project_service", return_value=mock_service),
pytest.raises(HTTPException) as exc_info,
):
await _resolve_project_slug(str(uid), MagicMock())
assert exc_info.value.status_code == _HTTP_404
assert str(uid) in exc_info.value.detail
+215
View File
@@ -0,0 +1,215 @@
"""Unit tests for /api/v2/do/* endpoints.
Uses a minimal FastAPI test client built from the do router only.
No DB required — ContentActions is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_content_actions
from roboco.api.routes.v2.do import router
from roboco.services.gateway.content_actions import ContentActions
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok",
task_id: str | None = None,
extra: dict | None = None,
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict = {"status": status, "task_id": task_id, "next": "continue"}
if extra:
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_actions: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the do router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_content_actions] = lambda: mock_actions
return app
@pytest.mark.asyncio
async def test_commit_descriptive_message_returns_ok() -> None:
"""POST /api/v2/do/commit with a descriptive message returns 200 ok."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.commit = AsyncMock(
return_value=_make_envelope(status="ok", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/commit",
json={"message": "add user authentication endpoint"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "ok"
mock_actions.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_commit_wip_message_returns_invalid_state() -> None:
"""POST /api/v2/do/commit with 'wip' returns invalid_state envelope."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.commit = AsyncMock(return_value=_make_envelope(status="invalid_state"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/commit",
json={"message": "wip"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "invalid_state"
mock_actions.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_note_reflect_scope_returns_ok() -> None:
"""POST /api/v2/do/note with scope='reflect' returns 200 ok."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.note = AsyncMock(return_value=_make_envelope(status="noted"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/note",
json={"text": "learned how HyDE works", "scope": "reflect"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "noted"
mock_actions.note.assert_awaited_once()
assert mock_actions.note.call_args.kwargs["scope"] == "reflect"
@pytest.mark.asyncio
async def test_note_garbage_scope_returns_invalid_state() -> None:
"""POST /api/v2/do/note with scope='garbage' returns invalid_state envelope."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.note = AsyncMock(return_value=_make_envelope(status="invalid_state"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/note",
json={"text": "some note", "scope": "garbage"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "invalid_state"
mock_actions.note.assert_awaited_once()
@pytest.mark.asyncio
async def test_say_with_explicit_task_id_returns_ok() -> None:
"""POST /api/v2/do/say with task_id explicit returns 200 ok."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.say = AsyncMock(
return_value=_make_envelope(status="posted", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/say",
json={"channel": "backend-cell", "text": "PR is ready", "task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "posted"
mock_actions.say.assert_awaited_once()
assert str(mock_actions.say.call_args.kwargs["task_id"]) == _TASK_ID
@pytest.mark.asyncio
async def test_say_without_task_id_auto_injects() -> None:
"""POST /api/v2/do/say with task_id null passes None; ContentActions injects."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.say = AsyncMock(
return_value=_make_envelope(status="posted", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/say",
json={"channel": "backend-cell", "text": "stand-up update"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
# task_id was None in the request body — handler passes it through as None
call_kwargs = mock_actions.say.call_args.kwargs
assert call_kwargs["task_id"] is None
mock_actions.say.assert_awaited_once()
@pytest.mark.asyncio
async def test_dm_with_no_task_context_returns_invalid_state() -> None:
"""POST /api/v2/do/dm with no task context returns invalid_state envelope."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.dm = AsyncMock(return_value=_make_envelope(status="invalid_state"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/dm",
json={"recipient": "be-qa-1", "text": "please review"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "invalid_state"
mock_actions.dm.assert_awaited_once()
@pytest.mark.asyncio
async def test_evidence_with_task_id_returns_evidence_envelope() -> None:
"""POST /api/v2/do/evidence with task_id returns 200 with evidence in response."""
evidence_payload = {"commits": ["abc123"], "diff_summary": "added 3 files"}
mock_actions = MagicMock(spec=ContentActions)
mock_actions.evidence = AsyncMock(
return_value=_make_envelope(
status="in_progress", task_id=_TASK_ID, extra={"evidence": evidence_payload}
)
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/evidence",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "in_progress"
assert "evidence" in body
assert body["evidence"]["commits"] == ["abc123"]
mock_actions.evidence.assert_awaited_once()
assert str(mock_actions.evidence.call_args.kwargs["task_id"]) == _TASK_ID
@@ -0,0 +1,81 @@
"""Unit tests for /api/v2/flow/auditor/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_auditor import router
_HTTP_200 = 200
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok", task_id: str | None = None, **extra: object
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_auditor router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_triage_returns_envelope() -> None:
"""POST /api/v2/flow/auditor/triage returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.auditor_triage = AsyncMock(
return_value=_make_envelope(status="blocked", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/auditor/triage",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "blocked"
mock_chore.auditor_triage.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_returns_envelope() -> None:
"""POST /api/v2/flow/auditor/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/auditor/i_am_idle",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.i_am_idle.assert_awaited_once()
+120
View File
@@ -0,0 +1,120 @@
"""Unit tests for /api/v2/flow/board/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_board import router
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok", task_id: str | None = None, **extra: object
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_board router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_triage_returns_envelope() -> None:
"""POST /api/v2/flow/board/triage returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.board_triage = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/triage",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_pm_review"
mock_chore.board_triage.assert_awaited_once()
@pytest.mark.asyncio
async def test_escalate_to_ceo_returns_envelope() -> None:
"""POST /api/v2/flow/board/escalate_to_ceo forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.escalate_to_ceo = AsyncMock(
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/escalate_to_ceo",
json={"task_id": _TASK_ID, "reason": "Strategic call needs CEO sign-off."},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_ceo_approval"
mock_chore.escalate_to_ceo.assert_awaited_once()
call_args = mock_chore.escalate_to_ceo.call_args
assert str(call_args.args[1]) == _TASK_ID
assert call_args.args[2] == "Strategic call needs CEO sign-off."
def test_escalate_to_ceo_validates_reason_required() -> None:
"""POST escalate_to_ceo rejects empty reason (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/escalate_to_ceo",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
@pytest.mark.asyncio
async def test_i_am_idle_returns_envelope() -> None:
"""POST /api/v2/flow/board/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/i_am_idle",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.i_am_idle.assert_awaited_once()
@@ -0,0 +1,218 @@
"""Unit tests for /api/v2/flow/cell_pm/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_cell_pm import router
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok", task_id: str | None = None, **extra: object
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_cell_pm router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/cell_pm/give_me_work returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/give_me_work",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.give_me_work.assert_awaited_once()
@pytest.mark.asyncio
async def test_triage_returns_envelope() -> None:
"""POST /api/v2/flow/cell_pm/triage returns 200 with task or idle status."""
mock_chore = MagicMock()
mock_chore.triage = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/triage",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_pm_review"
mock_chore.triage.assert_awaited_once()
@pytest.mark.asyncio
async def test_unblock_dispatches_task_id_with_restore_true() -> None:
"""POST /api/v2/flow/cell_pm/unblock forwards task_id and restore=True."""
mock_chore = MagicMock()
mock_chore.unblock = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/unblock",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "in_progress"
mock_chore.unblock.assert_awaited_once()
call_kwargs = mock_chore.unblock.call_args.kwargs
assert call_kwargs["restore"] is True
@pytest.mark.asyncio
async def test_unblock_with_restore_false() -> None:
"""POST /api/v2/flow/cell_pm/unblock forwards restore=False when specified."""
mock_chore = MagicMock()
mock_chore.unblock = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/unblock",
json={"task_id": _TASK_ID, "restore": False},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.unblock.assert_awaited_once()
call_kwargs = mock_chore.unblock.call_args.kwargs
assert call_kwargs["restore"] is False
@pytest.mark.asyncio
async def test_complete_dispatches_task_and_notes() -> None:
"""POST /api/v2/flow/cell_pm/complete forwards task_id and notes."""
mock_chore = MagicMock()
mock_chore.complete = AsyncMock(
return_value=_make_envelope(status="completed", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/complete",
json={"task_id": _TASK_ID, "notes": "All subtasks done, PR merged."},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "completed"
mock_chore.complete.assert_awaited_once()
call_args = mock_chore.complete.call_args
assert str(call_args.args[1]) == _TASK_ID
assert call_args.args[2] == "All subtasks done, PR merged."
@pytest.mark.asyncio
async def test_escalate_up_dispatches_reason() -> None:
"""POST /api/v2/flow/cell_pm/escalate_up forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.escalate_up = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": "Cross-cell dependency needs Main PM."},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.escalate_up.assert_awaited_once()
call_args = mock_chore.escalate_up.call_args
assert call_args.args[2] == "Cross-cell dependency needs Main PM."
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/cell_pm/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/i_am_idle",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.i_am_idle.assert_awaited_once()
def test_complete_rejects_empty_notes() -> None:
"""POST complete rejects empty notes (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/complete",
json={"task_id": _TASK_ID, "notes": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
def test_escalate_up_rejects_empty_reason() -> None:
"""POST escalate_up rejects empty reason (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
+190
View File
@@ -0,0 +1,190 @@
"""Unit tests for /api/v2/flow/dev/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_dev import router
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(status: str = "ok", task_id: str | None = None) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
env.as_dict.return_value = {"status": status, "task_id": task_id, "next": "..."}
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_dev router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/dev/give_me_work returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/give_me_work",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.give_me_work.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_will_work_on_dispatches_task_id() -> None:
"""POST /api/v2/flow/dev/i_will_work_on forwards task_id and plan."""
mock_chore = MagicMock()
mock_chore.i_will_work_on = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_will_work_on",
json={"task_id": _TASK_ID, "plan": "implement the feature"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "in_progress"
mock_chore.i_will_work_on.assert_awaited_once()
call_args = mock_chore.i_will_work_on.call_args
# second positional arg is task_id (UUID), third is plan
assert str(call_args.args[1]) == _TASK_ID
assert call_args.args[2] == "implement the feature"
@pytest.mark.asyncio
async def test_i_have_committed_dispatches_message() -> None:
"""POST /api/v2/flow/dev/i_have_committed forwards commit message."""
mock_chore = MagicMock()
mock_chore.i_have_committed = AsyncMock(
return_value=_make_envelope(status="in_progress")
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_have_committed",
json={"message": "add auth endpoint"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.i_have_committed.assert_awaited_once()
assert mock_chore.i_have_committed.call_args.args[1] == "add auth endpoint"
@pytest.mark.asyncio
async def test_i_am_done_dispatches_task_and_notes() -> None:
"""POST /api/v2/flow/dev/i_am_done forwards task_id and notes."""
mock_chore = MagicMock()
mock_chore.i_am_done = AsyncMock(
return_value=_make_envelope(status="awaiting_qa", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_done",
json={"task_id": _TASK_ID, "notes": "all tests pass"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_qa"
mock_chore.i_am_done.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_blocked_dispatches_reason() -> None:
"""POST /api/v2/flow/dev/i_am_blocked forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.i_am_blocked = AsyncMock(
return_value=_make_envelope(status="blocked", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_blocked",
json={"task_id": _TASK_ID, "reason": "waiting for design spec"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.i_am_blocked.assert_awaited_once()
assert mock_chore.i_am_blocked.call_args.args[2] == "waiting for design spec"
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/dev/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_idle",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.i_am_idle.assert_awaited_once()
def test_i_have_committed_rejects_empty_message() -> None:
"""POST i_have_committed rejects empty message (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_have_committed",
json={"message": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
def test_i_am_blocked_rejects_empty_reason() -> None:
"""POST i_am_blocked rejects empty reason (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_blocked",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
+160
View File
@@ -0,0 +1,160 @@
"""Unit tests for /api/v2/flow/documenter/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_doc import router
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok", task_id: str | None = None, **extra: object
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_doc router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/documenter/give_me_work returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/give_me_work",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.give_me_work.assert_awaited_once()
@pytest.mark.asyncio
async def test_claim_doc_task_dispatches_task_id() -> None:
"""POST /api/v2/flow/documenter/claim_doc_task forwards task_id."""
mock_chore = MagicMock()
mock_chore.claim_doc_task = AsyncMock(
return_value=_make_envelope(status="awaiting_documentation", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/claim_doc_task",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_documentation"
mock_chore.claim_doc_task.assert_awaited_once()
call_args = mock_chore.claim_doc_task.call_args
assert str(call_args.args[1]) == _TASK_ID
@pytest.mark.asyncio
async def test_i_documented_dispatches_notes_and_files() -> None:
"""POST /api/v2/flow/documenter/i_documented forwards notes and files."""
mock_chore = MagicMock()
mock_chore.i_documented = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_documented",
json={
"task_id": _TASK_ID,
"notes": "Documented the auth endpoint in docs/api/auth.md",
"files": ["docs/api/auth.md"],
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_pm_review"
mock_chore.i_documented.assert_awaited_once()
call_args = mock_chore.i_documented.call_args
assert str(call_args.args[1]) == _TASK_ID
assert call_args.args[2] == "Documented the auth endpoint in docs/api/auth.md"
assert call_args.args[3] == ["docs/api/auth.md"]
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/documenter/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_am_idle",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.i_am_idle.assert_awaited_once()
def test_i_documented_rejects_empty_notes() -> None:
"""POST i_documented rejects empty notes (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_documented",
json={"task_id": _TASK_ID, "notes": "", "files": ["docs/readme.md"]},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
def test_i_documented_rejects_empty_files_list() -> None:
"""POST i_documented rejects empty files list (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_documented",
json={"task_id": _TASK_ID, "notes": "some docs", "files": []},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
@@ -0,0 +1,176 @@
"""Unit tests for /api/v2/flow/main_pm/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_main_pm import router
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok", task_id: str | None = None, **extra: object
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_main_pm router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_triage_all_returns_envelope() -> None:
"""POST /api/v2/flow/main_pm/triage_all returns 200 with task or idle status."""
mock_chore = MagicMock()
mock_chore.triage_all = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/triage_all",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_pm_review"
mock_chore.triage_all.assert_awaited_once()
@pytest.mark.asyncio
async def test_complete_calls_main_pm_complete_directly() -> None:
"""POST /api/v2/flow/main_pm/complete calls main_pm_complete (not dispatch)."""
mock_chore = MagicMock()
mock_chore.main_pm_complete = AsyncMock(
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/complete",
json={"task_id": _TASK_ID, "notes": "Root task done, escalating to CEO."},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_ceo_approval"
mock_chore.main_pm_complete.assert_awaited_once()
call_args = mock_chore.main_pm_complete.call_args
assert str(call_args.args[1]) == _TASK_ID
assert call_args.args[2] == "Root task done, escalating to CEO."
@pytest.mark.asyncio
async def test_escalate_up_dispatches_reason() -> None:
"""POST /api/v2/flow/main_pm/escalate_up forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.escalate_up = AsyncMock(
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": "Needs CEO sign-off on architecture."},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.escalate_up.assert_awaited_once()
call_args = mock_chore.escalate_up.call_args
assert call_args.args[2] == "Needs CEO sign-off on architecture."
@pytest.mark.asyncio
async def test_unblock_dispatches_task_id_with_restore_true() -> None:
"""POST /api/v2/flow/main_pm/unblock forwards task_id with restore=True default."""
mock_chore = MagicMock()
mock_chore.unblock = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/unblock",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.unblock.assert_awaited_once()
call_kwargs = mock_chore.unblock.call_args.kwargs
assert call_kwargs["restore"] is True
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/main_pm/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/i_am_idle",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.i_am_idle.assert_awaited_once()
def test_complete_rejects_empty_notes() -> None:
"""POST complete rejects empty notes (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/complete",
json={"task_id": _TASK_ID, "notes": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
def test_escalate_up_rejects_empty_reason() -> None:
"""POST escalate_up rejects empty reason (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
+175
View File
@@ -0,0 +1,175 @@
"""Unit tests for /api/v2/flow/qa/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required — Choreographer is mocked.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_qa import router
_HTTP_200 = 200
_HTTP_422 = 422
_AGENT_ID = str(uuid4())
_TASK_ID = str(uuid4())
_HEADERS = {"X-Agent-ID": _AGENT_ID}
def _make_envelope(
status: str = "ok", task_id: str | None = None, **extra: object
) -> MagicMock:
"""Return a mock Envelope whose as_dict() returns a predictable payload."""
env = MagicMock()
payload: dict[str, object] = {"status": status, "task_id": task_id, "next": "..."}
payload.update(extra)
env.as_dict.return_value = payload
return env
def _build_app(mock_choreographer: MagicMock) -> FastAPI:
"""Build minimal FastAPI app with the flow_qa router and a mocked dep."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_choreographer] = lambda: mock_choreographer
return app
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/qa/give_me_work returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/give_me_work",
json={},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "idle"
mock_chore.give_me_work.assert_awaited_once()
@pytest.mark.asyncio
async def test_claim_review_dispatches_task_id() -> None:
"""POST /api/v2/flow/qa/claim_review returns 200 with evidence.pr_url in body."""
mock_chore = MagicMock()
mock_chore.claim_review = AsyncMock(
return_value=_make_envelope(
status="claimed",
task_id=_TASK_ID,
evidence={"pr_url": "https://github.com/org/repo/pull/42"},
)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/claim_review",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["evidence"]["pr_url"] == "https://github.com/org/repo/pull/42"
mock_chore.claim_review.assert_awaited_once()
call_args = mock_chore.claim_review.call_args
assert str(call_args.args[1]) == _TASK_ID
@pytest.mark.asyncio
async def test_pass_review_with_notes_returns_awaiting_documentation() -> None:
"""POST /api/v2/flow/qa/pass with notes returns 200 with awaiting_documentation."""
mock_chore = MagicMock()
mock_chore.pass_review = AsyncMock(
return_value=_make_envelope(status="awaiting_documentation", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/pass",
json={
"task_id": _TASK_ID,
"notes": "All acceptance criteria met, tests green.",
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "awaiting_documentation"
mock_chore.pass_review.assert_awaited_once()
call_args = mock_chore.pass_review.call_args
assert call_args.args[2] == "All acceptance criteria met, tests green."
@pytest.mark.asyncio
async def test_pass_review_short_notes_returns_tracing_gap_envelope() -> None:
"""POST /api/v2/flow/qa/pass with minimal notes relies on choreographer to gate."""
mock_chore = MagicMock()
mock_chore.pass_review = AsyncMock(
return_value=_make_envelope(status="tracing_gap", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/pass",
json={"task_id": _TASK_ID, "notes": "ok"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "tracing_gap"
mock_chore.pass_review.assert_awaited_once()
@pytest.mark.asyncio
async def test_fail_review_with_issues_returns_needs_revision() -> None:
"""POST /api/v2/flow/qa/fail with issues returns 200 with needs_revision status."""
mock_chore = MagicMock()
mock_chore.fail_review = AsyncMock(
return_value=_make_envelope(status="needs_revision", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/fail",
json={
"task_id": _TASK_ID,
"issues": ["Missing error handling", "No unit tests"],
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
body = resp.json()
assert body["status"] == "needs_revision"
mock_chore.fail_review.assert_awaited_once()
call_args = mock_chore.fail_review.call_args
assert call_args.args[2] == ["Missing error handling", "No unit tests"]
def test_fail_review_rejects_empty_issues_list() -> None:
"""POST /api/v2/flow/qa/fail with empty issues list is rejected with 422."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/fail",
json={"task_id": _TASK_ID, "issues": []},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422