feat(observability): propagate correlation_id end-to-end

X-Correlation-ID was bound to structlog but lost on the MCP→API hop.
MCP shims now forward it; Envelope carries it back to the agent;
audit_log row records it for forensic joins.
This commit is contained in:
Renn F
2026-05-03 08:37:25 +02:00
parent 99eac69aab
commit 074f47a2f9
14 changed files with 446 additions and 81 deletions
+22 -2
View File
@@ -1,4 +1,4 @@
"""Role-asserting dependencies for v2 flow routers.
"""Role-asserting dependencies and shared helpers for v2 flow routers.
Every router gets one of these as a dependency so the role check happens
before the choreographer body even runs. Defense in depth — the
@@ -7,10 +7,15 @@ choreographer also re-checks role internally for verbs that branch on it.
from __future__ import annotations
from typing import Annotated, cast
from typing import TYPE_CHECKING, Annotated, Any, cast
from fastapi import Depends, Header, HTTPException, params, status
if TYPE_CHECKING:
from fastapi import Request
from roboco.services.gateway.envelope import Envelope
def _require_roles(allowed: frozenset[str]) -> params.Depends:
def _check(
@@ -32,3 +37,18 @@ require_cell_pm = _require_roles(frozenset({"cell_pm"}))
require_main_pm = _require_roles(frozenset({"main_pm"}))
require_board = _require_roles(frozenset({"product_owner", "head_marketing"}))
require_auditor = _require_roles(frozenset({"auditor"}))
def envelope_to_response(env: Envelope, request: Request) -> dict[str, Any]:
"""Stamp the request's correlation_id onto the envelope and return wire-dict.
``CorrelationIdMiddleware`` writes the inbound (or freshly-generated)
``X-Correlation-ID`` to ``request.state.correlation_id``. We pull it
here so the agent receives the same id it sent (or can capture the
server-generated one) and ops can join logs across the full
MCP -> API -> service hop.
"""
cid = getattr(request.state, "correlation_id", None)
if cid is not None and env.correlation_id is None:
env.correlation_id = cid
return env.as_dict()
+12 -6
View File
@@ -3,9 +3,10 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_content_actions
from roboco.api.routes.v2._role_dep import envelope_to_response
from roboco.api.schemas.v2.do import (
CommitRequest,
DmRequest,
@@ -23,6 +24,7 @@ _ContentActionsDep = Annotated[ContentActions, Depends(get_content_actions)]
@router.post("/commit")
async def do_commit(
request: Request,
body: CommitRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
@@ -32,11 +34,12 @@ async def do_commit(
message=body.message,
files=body.files,
)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/note")
async def do_note(
request: Request,
body: NoteRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
@@ -47,11 +50,12 @@ async def do_note(
scope=body.scope,
task_id=body.task_id,
)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/say")
async def do_say(
request: Request,
body: SayRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
@@ -62,11 +66,12 @@ async def do_say(
text=body.text,
task_id=body.task_id,
)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/dm")
async def do_dm(
request: Request,
body: DmRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
@@ -78,14 +83,15 @@ async def do_dm(
task_id=body.task_id,
skill=body.skill,
)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/evidence")
async def do_evidence(
request: Request,
body: EvidenceRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
) -> dict:
env = await actions.evidence(agent_id=x_agent_id, task_id=body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
+6 -4
View File
@@ -6,10 +6,10 @@ Thin handlers; delegate to Choreographer.
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_auditor
from roboco.api.routes.v2._role_dep import envelope_to_response, require_auditor
from roboco.api.schemas.v2.flow import IAmIdleRequest, TriageRequest
from roboco.services.gateway.choreographer import Choreographer
@@ -26,19 +26,21 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/triage")
async def triage(
request: Request,
_body: TriageRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.auditor_triage(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+8 -5
View File
@@ -6,10 +6,10 @@ Thin handlers; delegate to Choreographer.
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_board
from roboco.api.routes.v2._role_dep import envelope_to_response, require_board
from roboco.api.schemas.v2.flow import (
EscalateToCeoRequest,
IAmIdleRequest,
@@ -30,29 +30,32 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/triage")
async def triage(
request: Request,
_body: TriageRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.board_triage(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/escalate_to_ceo")
async def escalate_to_ceo(
request: Request,
body: EscalateToCeoRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.escalate_to_ceo(x_agent_id, body.task_id, body.reason)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+24 -13
View File
@@ -3,10 +3,10 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_cell_pm
from roboco.api.routes.v2._role_dep import envelope_to_response, require_cell_pm
from roboco.api.schemas.v2.flow import (
CompleteRequest,
DelegateRequest,
@@ -35,26 +35,29 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/give_me_work")
async def give_me_work(
request: Request,
_body: GiveMeWorkRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.pm_give_me_work(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_will_plan")
async def i_will_plan(
request: Request,
body: IWillPlanRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_will_plan(x_agent_id, body.task_id, body.plan)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/delegate")
async def delegate(
request: Request,
body: DelegateRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
@@ -69,84 +72,92 @@ async def delegate(
estimated_complexity=body.estimated_complexity,
)
env = await choreographer.delegate(x_agent_id, body.parent_task_id, inputs)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/submit_up")
async def submit_up(
request: Request,
body: SubmitUpRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.submit_up(x_agent_id, body.task_id, body.notes)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/triage")
async def triage(
request: Request,
_body: TriageRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.triage(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unblock")
async def unblock(
request: Request,
body: UnblockRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unblock(x_agent_id, body.task_id, restore=body.restore)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/complete")
async def complete(
request: Request,
body: CompleteRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.complete(x_agent_id, body.task_id, body.notes)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/escalate_up")
async def escalate_up(
request: Request,
body: EscalateUpRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.escalate_up(x_agent_id, body.task_id, body.reason)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unclaim")
async def unclaim(
request: Request,
body: UnclaimRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unclaim(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/resume")
async def resume(
request: Request,
body: ResumeRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.resume(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+20 -11
View File
@@ -3,10 +3,10 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_dev
from roboco.api.routes.v2._role_dep import envelope_to_response, require_dev
from roboco.api.schemas.v2.flow import (
GiveMeWorkRequest,
IAmBlockedRequest,
@@ -33,89 +33,98 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/give_me_work")
async def give_me_work(
request: Request,
_body: GiveMeWorkRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.give_me_work(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_will_work_on")
async def i_will_work_on(
request: Request,
body: IWillWorkOnRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_will_work_on(x_agent_id, body.task_id, body.plan)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_have_committed")
async def i_have_committed(
request: Request,
body: IHaveCommittedRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_have_committed(x_agent_id, body.message)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/submit_for_qa")
async def submit_for_qa(
request: Request,
body: SubmitForQaRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.submit_for_qa(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_done")
async def i_am_done(
request: Request,
body: IAmDoneRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_done(x_agent_id, body.task_id, body.notes)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_blocked")
async def i_am_blocked(
request: Request,
body: IAmBlockedRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_blocked(x_agent_id, body.task_id, body.reason)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unclaim")
async def unclaim(
request: Request,
body: UnclaimRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unclaim(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/resume")
async def resume(
request: Request,
body: ResumeRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.resume(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+14 -8
View File
@@ -3,10 +3,10 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_doc
from roboco.api.routes.v2._role_dep import envelope_to_response, require_doc
from roboco.api.schemas.v2.flow import (
ClaimDocTaskRequest,
GiveMeWorkRequest,
@@ -30,26 +30,29 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/give_me_work")
async def give_me_work(
request: Request,
_body: GiveMeWorkRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.give_me_work(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/claim_doc_task")
async def claim_doc_task(
request: Request,
body: ClaimDocTaskRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.claim_doc_task(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_documented")
async def i_documented(
request: Request,
body: IDocumentedRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
@@ -57,34 +60,37 @@ async def i_documented(
env = await choreographer.i_documented(
x_agent_id, body.task_id, body.notes, body.files
)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unclaim")
async def unclaim(
request: Request,
body: UnclaimRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unclaim(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/resume")
async def resume(
request: Request,
body: ResumeRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.resume(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+24 -13
View File
@@ -3,10 +3,10 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_main_pm
from roboco.api.routes.v2._role_dep import envelope_to_response, require_main_pm
from roboco.api.schemas.v2.flow import (
CompleteRequest,
DelegateRequest,
@@ -35,26 +35,29 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/give_me_work")
async def give_me_work(
request: Request,
_body: GiveMeWorkRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.pm_give_me_work(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_will_plan")
async def i_will_plan(
request: Request,
body: IWillPlanRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_will_plan(x_agent_id, body.task_id, body.plan)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/delegate")
async def delegate(
request: Request,
body: DelegateRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
@@ -69,84 +72,92 @@ async def delegate(
estimated_complexity=body.estimated_complexity,
)
env = await choreographer.delegate(x_agent_id, body.parent_task_id, inputs)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/triage_all")
async def triage_all(
request: Request,
_body: TriageRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.triage_all(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/complete")
async def complete(
request: Request,
body: CompleteRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.main_pm_complete(x_agent_id, body.task_id, body.notes)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/escalate_up")
async def escalate_up(
request: Request,
body: EscalateUpRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.escalate_up(x_agent_id, body.task_id, body.reason)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/escalate_to_ceo")
async def escalate_to_ceo(
request: Request,
body: EscalateToCeoRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.escalate_to_ceo(x_agent_id, body.task_id, body.reason)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unblock")
async def unblock(
request: Request,
body: UnblockRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unblock(x_agent_id, body.task_id, restore=body.restore)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unclaim")
async def unclaim(
request: Request,
body: UnclaimRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unclaim(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/resume")
async def resume(
request: Request,
body: ResumeRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.resume(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+16 -9
View File
@@ -3,10 +3,10 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2._role_dep import require_qa
from roboco.api.routes.v2._role_dep import envelope_to_response, require_qa
from roboco.api.schemas.v2.flow import (
ClaimReviewRequest,
FailReviewRequest,
@@ -31,69 +31,76 @@ _ChoreographerDep = Annotated[Choreographer, Depends(get_choreographer)]
@router.post("/give_me_work")
async def give_me_work(
request: Request,
_body: GiveMeWorkRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.give_me_work(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/claim_review")
async def claim_review(
request: Request,
body: ClaimReviewRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.claim_review(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/pass")
async def qa_pass(
request: Request,
body: PassReviewRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.pass_review(x_agent_id, body.task_id, body.notes)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/fail")
async def qa_fail(
request: Request,
body: FailReviewRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.fail_review(x_agent_id, body.task_id, body.issues)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/unclaim")
async def unclaim(
request: Request,
body: UnclaimRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.unclaim(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/resume")
async def resume(
request: Request,
body: ResumeRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.resume(x_agent_id, body.task_id)
return env.as_dict()
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
_body: IAmIdleRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_am_idle(x_agent_id)
return env.as_dict()
return envelope_to_response(env, request)
+16 -2
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
from typing import Any
@@ -28,19 +29,32 @@ ORCHESTRATOR_URL = os.environ.get(
AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"]
_HEADERS = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE}
_TIMEOUT = 30
mcp = FastMCP("roboco-do")
log = structlog.get_logger()
def _build_headers() -> dict[str, str]:
"""Build per-call headers including a fresh X-Correlation-ID.
Mirrors flow_server: each MCP call mints its own correlation id so the
orchestrator's middleware can bind it to structlog and the audit row,
and the envelope echoes it back to the agent.
"""
return {
"X-Agent-ID": AGENT_ID,
"X-Agent-Role": AGENT_ROLE,
"X-Correlation-ID": str(uuid.uuid4()),
}
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
"""POST a request to the orchestrator and return the JSON envelope."""
with httpx.Client(timeout=_TIMEOUT) as client:
response = client.post(
f"{ORCHESTRATOR_URL}{path}",
headers=_HEADERS,
headers=_build_headers(),
json=body,
)
response.raise_for_status()
+18 -2
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
from typing import Any
@@ -30,19 +31,34 @@ ORCHESTRATOR_URL = os.environ.get(
AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"]
_HEADERS = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE}
_TIMEOUT = 30
mcp = FastMCP("roboco-flow")
log = structlog.get_logger()
def _build_headers() -> dict[str, str]:
"""Build per-call headers including a fresh X-Correlation-ID.
The agent runtime is the first hop, so we mint a UUID per MCP call.
The orchestrator's ``CorrelationIdMiddleware`` will accept this as the
inbound id and bind it to the structlog context for the request, so
every log line and the audit row carry the same id and the agent
receives it back on the envelope.
"""
return {
"X-Agent-ID": AGENT_ID,
"X-Agent-Role": AGENT_ROLE,
"X-Correlation-ID": str(uuid.uuid4()),
}
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
"""POST a request to the orchestrator and return the JSON envelope."""
with httpx.Client(timeout=_TIMEOUT) as client:
response = client.post(
f"{ORCHESTRATOR_URL}{path}",
headers=_HEADERS,
headers=_build_headers(),
json=body,
)
response.raise_for_status()
+15 -6
View File
@@ -146,20 +146,29 @@ class Choreographer:
return is the only fast path. Audit writes are best-effort
failures must NEVER block the verb (the agent's response is the
contract; the audit row is observability-only).
Also stashes ``correlation_id`` from the structlog contextvars
(bound by ``CorrelationIdMiddleware`` for the inbound request)
into the audit row's ``details`` JSONB so post-mortem joins
across logs and audit trail are possible.
"""
if env.error is None:
return env
details: dict[str, Any] = {
"verb": verb,
"reason": env.error,
"message": env.message,
"missing": env.missing or [],
}
cid = structlog.contextvars.get_contextvars().get("correlation_id")
if cid is not None:
details["correlation_id"] = cid
try:
await self.audit.log_event(
event_type="gateway.rejected",
agent_id=agent_id,
task_id=task_id,
details={
"verb": verb,
"reason": env.error,
"message": env.message,
"missing": env.missing or [],
},
details=details,
)
except Exception as exc:
# Audit is best-effort: it must NEVER block the verb. The agent's
+13
View File
@@ -3,6 +3,13 @@
Every successful verb returns Envelope.ok(...). Every error returns one of
Envelope.tracing_gap / invalid_state / not_authorized / not_found. The
shape is the single contract that MCP servers convert into JSON for agents.
``correlation_id`` is intentionally NOT a constructor argument it is a
transport-layer concern stamped post-construction by the route handler
from ``request.state.correlation_id`` (see
``api.routes.v2._role_dep.envelope_to_response``). Verb logic must never
thread it through; doing so would mix request lifecycle into business
logic.
"""
from __future__ import annotations
@@ -26,6 +33,11 @@ class Envelope:
message: str | None = None
remediate: str | None = None
missing: list[str] | None = None
# Stamped post-construction by the route layer from
# ``request.state.correlation_id`` (set by ``CorrelationIdMiddleware``).
# Carried back to the agent so the same id flows MCP -> API -> agent
# and ops can join logs across the full hop.
correlation_id: str | None = None
@classmethod
def ok(
@@ -103,6 +115,7 @@ class Envelope:
"evidence": self.evidence or {},
"context_briefing": self.context_briefing,
"error": self.error,
"correlation_id": self.correlation_id,
}
if self.error is not None:
out["message"] = self.message
+238
View File
@@ -0,0 +1,238 @@
"""End-to-end correlation_id propagation: header -> envelope -> audit row.
Audit F20 surfaced that ``X-Correlation-ID`` was bound to structlog by the
``CorrelationIdMiddleware`` but never travelled past the API boundary:
* The MCP shims (``flow_server`` / ``do_server``) didn't forward it, so
every MCP -> API hop got a fresh server-generated UUID.
* The Envelope returned to the agent had no slot to carry the id back.
* The ``audit_log`` rows the choreographer writes had no correlation_id
field, so post-mortem joins across logs and audit trail were impossible.
These tests pin the contract:
1. Envelope holds an optional ``correlation_id`` and round-trips it via
``as_dict()``.
2. The v2 flow route reads ``request.state.correlation_id`` (set by
``CorrelationIdMiddleware``) and stamps it onto the envelope before
returning.
3. Both MCP shims attach an ``X-Correlation-ID`` header on every POST,
mirroring how they attach ``X-Agent-ID`` / ``X-Agent-Role``.
4. The choreographer's ``_emit_rejection`` audit writer pulls the
correlation_id from the structlog contextvars (where the middleware
binds it) and stuffs it into the audit row's ``details`` dict.
"""
from __future__ import annotations
import asyncio
import importlib
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
import structlog
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.middleware import CorrelationIdMiddleware
from roboco.api.routes.v2.flow_dev import router as flow_dev_router
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.envelope import Envelope
if TYPE_CHECKING:
from types import ModuleType
_HTTP_200 = 200
_DEV_AGENT_HEADERS = {
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
"X-Agent-Role": "developer",
}
# --- Envelope ----------------------------------------------------------------
def test_envelope_ok_carries_correlation_id_when_stamped() -> None:
"""correlation_id is set post-construction by the transport layer."""
env = Envelope.ok(status="idle", next="call i_am_idle()")
env.correlation_id = "test-id-123"
assert env.correlation_id == "test-id-123"
assert env.as_dict()["correlation_id"] == "test-id-123"
def test_envelope_error_carries_correlation_id_when_stamped() -> None:
env = Envelope.invalid_state(message="bad state", remediate="do X first")
env.correlation_id = "abc"
assert env.correlation_id == "abc"
assert env.as_dict()["correlation_id"] == "abc"
def test_envelope_correlation_id_defaults_to_none() -> None:
env = Envelope.ok(status="idle", next="call i_am_idle()")
assert env.correlation_id is None
# When None we still emit the key so consumers don't have to special-case.
assert env.as_dict()["correlation_id"] is None
# --- Route -> Envelope wiring ------------------------------------------------
def _build_app() -> tuple[FastAPI, MagicMock]:
app = FastAPI()
app.add_middleware(CorrelationIdMiddleware)
app.include_router(flow_dev_router)
mock_chore = MagicMock()
mock_envelope = Envelope.ok(status="idle", next="...")
mock_chore.give_me_work = AsyncMock(return_value=mock_envelope)
app.dependency_overrides[get_choreographer] = lambda: mock_chore
return app, mock_chore
def test_route_stamps_request_correlation_id_onto_envelope() -> None:
app, _ = _build_app()
client = TestClient(app)
r = client.post(
"/api/v2/flow/dev/give_me_work",
json={},
headers={**_DEV_AGENT_HEADERS, "X-Correlation-ID": "trace-xyz"},
)
assert r.status_code == _HTTP_200
assert r.json()["correlation_id"] == "trace-xyz"
# Middleware also echoes the header back, so ops can grep for it.
assert r.headers["X-Correlation-ID"] == "trace-xyz"
def test_route_stamps_generated_correlation_id_when_header_missing() -> None:
app, _ = _build_app()
client = TestClient(app)
r = client.post(
"/api/v2/flow/dev/give_me_work",
json={},
headers=_DEV_AGENT_HEADERS,
)
assert r.status_code == _HTTP_200
body_id = r.json()["correlation_id"]
header_id = r.headers["X-Correlation-ID"]
# Middleware generates a UUID and binds it. The route must read the
# SAME id back from request.state and stamp it onto the envelope.
assert body_id is not None
assert body_id == header_id
# --- MCP shims ---------------------------------------------------------------
def _reload_mcp_module(monkeypatch: pytest.MonkeyPatch, dotted: str) -> ModuleType:
"""Set the env vars MCP servers expect at import-time and reload the module.
Both servers read AGENT_ID / AGENT_ROLE / ORCHESTRATOR_URL once at
import; we have to re-import after monkey-patching so the test sees
the patched values. The reload itself is the lazy import keeping
importlib at the top-level keeps PLC0415 happy.
"""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
module = importlib.import_module(dotted)
return importlib.reload(module)
@pytest.fixture
def flow_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
return _reload_mcp_module(monkeypatch, "roboco.mcp.flow_server")
@pytest.fixture
def do_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
return _reload_mcp_module(monkeypatch, "roboco.mcp.do_server")
def _fake_client(payload: dict[str, Any]) -> MagicMock:
fake_response = MagicMock()
fake_response.json.return_value = payload
fake_client = MagicMock()
fake_client.__enter__ = MagicMock(return_value=fake_client)
fake_client.__exit__ = MagicMock(return_value=False)
fake_client.post.return_value = fake_response
return fake_client
def test_flow_server_attaches_correlation_id_header(
flow_module: ModuleType,
) -> None:
fake = _fake_client({"status": "idle"})
with patch("httpx.Client", return_value=fake):
flow_module.give_me_work()
_args, kwargs = fake.post.call_args
headers = kwargs["headers"]
assert headers["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001"
assert headers["X-Agent-Role"] == "developer"
assert "X-Correlation-ID" in headers
assert headers["X-Correlation-ID"] # non-empty
def test_flow_server_generates_unique_correlation_id_per_call(
flow_module: ModuleType,
) -> None:
fake = _fake_client({"status": "idle"})
with patch("httpx.Client", return_value=fake):
flow_module.give_me_work()
flow_module.give_me_work()
first = fake.post.call_args_list[0].kwargs["headers"]["X-Correlation-ID"]
second = fake.post.call_args_list[1].kwargs["headers"]["X-Correlation-ID"]
assert first != second
def test_do_server_attaches_correlation_id_header(
do_module: ModuleType,
) -> None:
fake = _fake_client({"status": "noted"})
with patch("httpx.Client", return_value=fake):
do_module.note("hi")
_args, kwargs = fake.post.call_args
headers = kwargs["headers"]
assert headers["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001"
assert headers["X-Agent-Role"] == "developer"
assert "X-Correlation-ID" in headers
assert headers["X-Correlation-ID"]
# --- Audit-row stash ---------------------------------------------------------
def test_choreographer_emit_rejection_includes_correlation_id_in_details() -> None:
"""Audit row's `details` dict carries correlation_id from contextvars."""
audit = MagicMock()
audit.log_event = AsyncMock()
deps = ChoreographerDeps(
task=MagicMock(),
work_session=MagicMock(),
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
audit=audit,
evidence_repo=MagicMock(),
)
chore = Choreographer(deps)
bad_env = Envelope.invalid_state(message="oops", remediate="do X")
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(correlation_id="cid-9")
try:
asyncio.run(
chore._emit_rejection(
bad_env,
agent_id=UUID("00000000-0000-0000-0000-000000000001"),
task_id=None,
verb="i_will_work_on",
)
)
finally:
structlog.contextvars.clear_contextvars()
audit.log_event.assert_awaited_once()
kwargs = audit.log_event.await_args.kwargs
assert kwargs["details"]["correlation_id"] == "cid-9"