diff --git a/api/models/capability_request.py b/api/models/capability_request.py index 7051390..d30d8f7 100644 --- a/api/models/capability_request.py +++ b/api/models/capability_request.py @@ -66,6 +66,13 @@ class CapabilityRequest(Base, TimestampMixin): resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True) routing_note: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Dispute fields (populated when status = routing_disputed) + dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True) + disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/api/routers/capability_requests.py b/api/routers/capability_requests.py index b46f313..810cab4 100644 --- a/api/routers/capability_requests.py +++ b/api/routers/capability_requests.py @@ -17,8 +17,10 @@ from api.schemas.capability_request import ( CatalogRead, CapabilityRequestAccept, CapabilityRequestCreate, + CapabilityRequestDispute, CapabilityRequestPatch, CapabilityRequestRead, + CapabilityRequestReroute, CapabilityRequestStatusPatch, ) @@ -29,13 +31,14 @@ router = APIRouter(tags=["capability-requests"]) # --------------------------------------------------------------------------- _VALID_TRANSITIONS: dict[str, set[str]] = { - "requested": {"accepted", "rejected", "withdrawn"}, - "accepted": {"in_progress", "rejected", "withdrawn"}, - "in_progress": {"ready_for_review", "rejected", "withdrawn"}, - "ready_for_review": {"completed", "in_progress", "withdrawn"}, - "completed": set(), - "rejected": set(), - "withdrawn": set(), + "requested": {"accepted", "rejected", "withdrawn", "routing_disputed"}, + "routing_disputed": {"requested", "withdrawn"}, + "accepted": {"in_progress", "rejected", "withdrawn"}, + "in_progress": {"ready_for_review", "rejected", "withdrawn"}, + "ready_for_review": {"completed", "in_progress", "withdrawn"}, + "completed": set(), + "rejected": set(), + "withdrawn": set(), } @@ -331,6 +334,138 @@ async def patch_request( return req +# --------------------------------------------------------------------------- +# Dispute endpoints +# --------------------------------------------------------------------------- + +@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead) +async def dispute_request( + request_id: uuid.UUID, + body: CapabilityRequestDispute, + session: AsyncSession = Depends(get_session), +) -> CapabilityRequest: + """Flag a routing decision as incorrect. Transitions to routing_disputed.""" + req = await _get_request_or_404(request_id, session) + _check_transition(req.status, "routing_disputed") + + now = datetime.now(tz=timezone.utc) + req.status = "routing_disputed" + req.dispute_reason = body.reason + req.disputed_by = body.disputed_by + req.dispute_suggested_domain = body.suggested_domain + req.disputed_at = now + + dispute_entry = ( + f"disputed by {body.disputed_by}: {body.reason}" + + (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "") + ) + req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry + + # Notify custodian + _add_notification( + session, + from_agent=body.disputed_by, + to_agent="custodian", + subject=f"[routing-disputed] {req.title}", + body=( + f"**{body.disputed_by}** has disputed the routing of capability request " + f"**{req.title}**.\n\n" + f"**Reason:** {body.reason}\n" + + (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "") + + f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}" + ), + ) + # Notify current fulfilling domain + if req.fulfilling_domain_slug: + _add_notification( + session, + from_agent=body.disputed_by, + to_agent=req.fulfilling_domain_slug, + subject=f"[routing-disputed] {req.title}", + body=( + f"The capability request **{req.title}** routed to your domain has been disputed " + f"by **{body.disputed_by}**.\n\n" + f"**Reason:** {body.reason}\n" + + (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "") + ), + ) + + await session.commit() + await session.refresh(req) + return req + + +@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead) +async def reroute_request( + request_id: uuid.UUID, + body: CapabilityRequestReroute, + session: AsyncSession = Depends(get_session), +) -> CapabilityRequest: + """Re-route a disputed request to a new domain. Resets to requested.""" + req = await _get_request_or_404(request_id, session) + if req.status != "routing_disputed": + raise HTTPException( + status_code=422, + detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.", + ) + if body.catalog_entry_id is None and body.domain is None: + raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.") + + if body.catalog_entry_id is not None: + entry = await session.get(CapabilityCatalog, body.catalog_entry_id) + if entry is None: + raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found") + req.catalog_entry_id = entry.id + req.fulfilling_domain_id = entry.domain_id + new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown" + else: + new_domain = await _resolve_domain(body.domain, session) + req.fulfilling_domain_id = new_domain.id + new_domain_slug = new_domain.slug + + old_domain = req.dispute_suggested_domain or "unknown" + + # Clear dispute fields + req.dispute_reason = None + req.disputed_by = None + req.dispute_suggested_domain = None + req.disputed_at = None + req.status = "requested" + + reroute_entry = f"re-routed by {body.rerouted_by} → {new_domain_slug}: {body.note}" + req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry + + # Notify requester + _add_notification( + session, + from_agent=body.rerouted_by, + to_agent=req.requesting_agent, + subject=f"[re-routed] {req.title}", + body=( + f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n" + f"**Note:** {body.note}" + ), + ) + # Notify new fulfilling domain + _add_notification( + session, + from_agent=body.rerouted_by, + to_agent=new_domain_slug, + subject=f"[capability-request] {req.title}", + body=( + f"Capability request **{req.title}** has been re-routed to your domain.\n\n" + f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n" + f"**Type:** {req.capability_type}\n" + f"**Priority:** {req.priority}\n\n" + f"{req.description or '(no description)'}" + ), + ) + + await session.commit() + await session.refresh(req) + return req + + # --------------------------------------------------------------------------- # Routing algorithm # --------------------------------------------------------------------------- diff --git a/api/schemas/capability_request.py b/api/schemas/capability_request.py index 6c0c8e4..481ff98 100644 --- a/api/schemas/capability_request.py +++ b/api/schemas/capability_request.py @@ -62,6 +62,19 @@ class CapabilityRequestPatch(BaseModel): fulfilling_workstream_id: uuid.UUID | None = None +class CapabilityRequestDispute(BaseModel): + reason: str + disputed_by: str + suggested_domain: str | None = None + + +class CapabilityRequestReroute(BaseModel): + note: str + rerouted_by: str + domain: str | None = None # slug — used if catalog_entry_id not given + catalog_entry_id: uuid.UUID | None = None # preferred: re-derives domain + + class CapabilityRequestRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -81,6 +94,10 @@ class CapabilityRequestRead(BaseModel): catalog_entry_id: uuid.UUID | None = None resolution_note: str | None = None routing_note: str | None = None + dispute_reason: str | None = None + disputed_by: str | None = None + dispute_suggested_domain: str | None = None + disputed_at: datetime | None = None accepted_at: datetime | None = None completed_at: datetime | None = None created_at: datetime diff --git a/dashboard/src/capability-requests.md b/dashboard/src/capability-requests.md index f7782d2..741cc6b 100644 --- a/dashboard/src/capability-requests.md +++ b/dashboard/src/capability-requests.md @@ -48,7 +48,7 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities ```js // KPI sidebar -const open = requests.filter(r => ["requested","accepted","in_progress","ready_for_review"].includes(r.status)); +const open = requests.filter(r => ["requested","routing_disputed","accepted","in_progress","ready_for_review"].includes(r.status)); const completed = requests.filter(r => r.status === "completed"); const avgFulfill = completed.length > 0 ? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1) @@ -74,7 +74,7 @@ const typeFilter = Inputs.select( {label: "Type", value: "all"} ); const statFilter = Inputs.select( - ["all", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"], + ["all", "routing_disputed", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"], {label: "Status", value: "all"} ); const domFilter = Inputs.select( @@ -102,6 +102,26 @@ const filtered = requests.filter(r => ```js const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"}; +const disputed = requests.filter(r => r.status === "routing_disputed"); + +// Disputed banner — shown at top when any exist +if (disputed.length > 0) { + display(html`
+
⚠ Routing Disputed (${disputed.length})
+ ${disputed.map(r => html` +
+
+ ${r.title} + ${r.requesting_domain_slug} → ${r.fulfilling_domain_slug ?? "unassigned"} +
+
Dispute: ${r.dispute_reason ?? "(no reason given)"}
+ ${r.dispute_suggested_domain ? html`
Suggested domain: ${r.dispute_suggested_domain}
` : ""} + ${r.disputed_by ? html`
Raised by ${r.disputed_by} · ${new Date(r.disputed_at).toLocaleString()}
` : ""} +
+ `)} +
`); +} + display(html`

Requested

${requests.filter(r => r.status === "requested").length}

In Progress

${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}

@@ -114,13 +134,14 @@ display(html`
diff --git a/mcp_server/server.py b/mcp_server/server.py index c33fa56..575413c 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -1951,6 +1951,53 @@ def get_capability_request(request_id: str) -> str: return json.dumps(_get(f"/capability-requests/{request_id}"), indent=2) +@mcp.tool() +def dispute_capability_routing( + request_id: str, + reason: str, + disputed_by: str, + suggested_domain: Optional[str] = None, +) -> str: + """Flag a capability request routing as incorrect. Transitions to routing_disputed. + + Args: + request_id: UUID of the capability request + reason: Why the routing is wrong + disputed_by: Agent raising the dispute (e.g. 'netkingdom-worker') + suggested_domain: The domain slug this should be routed to (optional) + """ + return json.dumps(_post(f"/capability-requests/{request_id}/dispute", { + "reason": reason, + "disputed_by": disputed_by, + "suggested_domain": suggested_domain, + }), indent=2) + + +@mcp.tool() +def reroute_capability_request( + request_id: str, + note: str, + rerouted_by: str, + domain: Optional[str] = None, + catalog_entry_id: Optional[str] = None, +) -> str: + """Re-route a disputed capability request to a new domain. Resets to requested. + + Args: + request_id: UUID of the capability request (must be routing_disputed) + note: Reason for the re-routing decision + rerouted_by: Agent performing the re-route (e.g. 'custodian') + domain: Target domain slug (used if catalog_entry_id not provided) + catalog_entry_id: Preferred — UUID of catalog entry; re-derives domain automatically + """ + return json.dumps(_post(f"/capability-requests/{request_id}/reroute", { + "note": note, + "rerouted_by": rerouted_by, + "domain": domain, + "catalog_entry_id": catalog_entry_id, + }), indent=2) + + # --------------------------------------------------------------------------- # Third-Party Services Catalog (TPSC) # --------------------------------------------------------------------------- diff --git a/migrations/versions/m0h1i2j3k4l5_capability_request_dispute.py b/migrations/versions/m0h1i2j3k4l5_capability_request_dispute.py new file mode 100644 index 0000000..0a0415a --- /dev/null +++ b/migrations/versions/m0h1i2j3k4l5_capability_request_dispute.py @@ -0,0 +1,27 @@ +"""Add dispute columns to capability_requests + +Revision ID: m0h1i2j3k4l5 +Revises: l9g0h1i2j3k4 +Create Date: 2026-03-21 +""" +from alembic import op +import sqlalchemy as sa + +revision = 'm0h1i2j3k4l5' +down_revision = 'l9g0h1i2j3k4' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('capability_requests', sa.Column('dispute_reason', sa.Text(), nullable=True)) + op.add_column('capability_requests', sa.Column('disputed_by', sa.String(100), nullable=True)) + op.add_column('capability_requests', sa.Column('dispute_suggested_domain', sa.String(100), nullable=True)) + op.add_column('capability_requests', sa.Column('disputed_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column('capability_requests', 'disputed_at') + op.drop_column('capability_requests', 'dispute_suggested_domain') + op.drop_column('capability_requests', 'disputed_by') + op.drop_column('capability_requests', 'dispute_reason')