Files
state-hub/api/routers/decisions.py
tegwick 533fecd6e1 Add in-dashboard decision resolution with project log write
API:
- DecisionResolve schema (rationale, decided_by, write_log flag)
- POST /decisions/{id}/resolve — marks resolved, emits progress event,
  appends entry to DECISIONS.md in the project's registered directory
  (found via the topic's registration milestone event)

Dashboard:
- Replace Inputs.table for blocking decisions with full-text cards
- Each card shows title, full description (pre-wrap), rationale/context,
  escalation warning if present
- Expandable "Resolve →" section with rationale textarea, decided-by
  input, submit button that calls the resolve endpoint
- On success: collapses form, dims card, confirms log was written

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:34:35 +01:00

197 lines
6.4 KiB
Python

import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
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)
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:
return
p = Path(project_path)
if not p.is_dir():
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
)