import asyncio import logging import uuid from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status logger = logging.getLogger(__name__) from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.progress_event import ProgressEvent from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate router = APIRouter(prefix="/decisions", tags=["decisions"]) _FINANCIAL_LEGAL_KEYWORDS = ( "financ", "legal", "payment", "purchas", "contract", "commit", "obligation", "external representation", ) def _needs_escalation(body: DecisionCreate) -> str | None: if body.decision_type != DecisionType.pending: return None text = f"{body.title} {body.description or ''}".lower() for kw in _FINANCIAL_LEGAL_KEYWORDS: if kw in text: return ( "Auto-escalated per constitution ยง4: this pending decision touches " "financial or legal territory and requires explicit human approval before action." ) return None @router.get("/", response_model=list[DecisionRead]) async def list_decisions( topic_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None, status: DecisionStatus | None = None, decision_type: DecisionType | None = None, session: AsyncSession = Depends(get_session), ) -> list[Decision]: q = select(Decision) if topic_id: q = q.where(Decision.topic_id == topic_id) if workstream_id: q = q.where(Decision.workstream_id == workstream_id) if status: q = q.where(Decision.status == status) if decision_type: q = q.where(Decision.decision_type == decision_type) q = q.order_by(Decision.created_at) result = await session.execute(q) return list(result.scalars().all()) @router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED) async def create_decision( body: DecisionCreate, session: AsyncSession = Depends(get_session), ) -> Decision: data = body.model_dump() note = _needs_escalation(body) if note: data["escalation_note"] = note data["status"] = DecisionStatus.escalated decision = Decision(**data) session.add(decision) await session.commit() await session.refresh(decision) return decision @router.get("/{decision_id}", response_model=DecisionRead) async def get_decision( decision_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Decision: decision = await session.get(Decision, decision_id) if decision is None: raise HTTPException(status_code=404, detail="Decision not found") return decision @router.patch("/{decision_id}", response_model=DecisionRead) async def update_decision( decision_id: uuid.UUID, body: DecisionUpdate, session: AsyncSession = Depends(get_session), ) -> Decision: decision = await session.get(Decision, decision_id) if decision is None: raise HTTPException(status_code=404, detail="Decision not found") for field, value in body.model_dump(exclude_unset=True).items(): setattr(decision, field, value) await session.commit() await session.refresh(decision) return decision @router.delete("/{decision_id}", response_model=DecisionRead) async def supersede_decision( decision_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> Decision: decision = await session.get(Decision, decision_id) if decision is None: raise HTTPException(status_code=404, detail="Decision not found") decision.status = DecisionStatus.superseded await session.commit() await session.refresh(decision) return decision @router.post("/{decision_id}/resolve", response_model=DecisionRead) async def resolve_decision_action( decision_id: uuid.UUID, body: DecisionResolve, session: AsyncSession = Depends(get_session), ) -> Decision: decision = await session.get(Decision, decision_id) if decision is None: raise HTTPException(status_code=404, detail="Decision not found") if decision.status == DecisionStatus.resolved: raise HTTPException(status_code=409, detail="Decision already resolved") decision.status = DecisionStatus.resolved decision.decision_type = DecisionType.made decision.rationale = body.rationale decision.decided_by = body.decided_by decision.decided_at = datetime.now(tz=timezone.utc) await session.commit() await session.refresh(decision) event = ProgressEvent( topic_id=decision.topic_id, workstream_id=decision.workstream_id, decision_id=decision.id, event_type="decision_resolved", summary=f"Decision resolved: {decision.title}", author=body.decided_by, detail={"rationale": body.rationale}, ) session.add(event) await session.commit() if body.write_log: await _write_project_log(decision, body.rationale, body.decided_by, session) subject = "org.statehub.decision.resolved" envelope = EventEnvelope.new( subject, attributes={ "decision_id": str(decision.id), "title": decision.title, "topic_id": str(decision.topic_id) if decision.topic_id else None, "workstream_id": str(decision.workstream_id) if decision.workstream_id else None, "decided_by": body.decided_by, "rationale_snippet": (body.rationale or "")[:240], }, ) asyncio.create_task(publish_event(subject, envelope)) return decision async def _write_project_log( decision: Decision, rationale: str, decided_by: str, session: AsyncSession ) -> None: """Append a DECISIONS.md entry to the registered project directory for this topic.""" if decision.topic_id is None: return rows = await session.execute( select(ProgressEvent) .where(ProgressEvent.topic_id == decision.topic_id) .where(ProgressEvent.event_type == "milestone") .order_by(ProgressEvent.created_at.desc()) ) project_path: str | None = None for pe in rows.scalars(): if pe.summary and "Project registered with State Hub:" in pe.summary: project_path = (pe.detail or {}).get("project_path") if project_path: break if not project_path: logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id) return p = Path(project_path) if not p.is_dir(): logger.warning("write_log requested but project_path does not exist: %s", project_path) return now = datetime.now(tz=timezone.utc) entry = ( f"\n## {decision.title}\n\n" f"**Date:** {now.strftime('%Y-%m-%d')} \n" f"**Decided by:** {decided_by} \n\n" f"{rationale}\n\n" f"---\n" ) log_file = p / "DECISIONS.md" if log_file.exists(): log_file.write_text(log_file.read_text() + entry) else: log_file.write_text( "# Decision Log\n\n" "_Auto-generated by the Custodian State Hub._\n" + entry )