diff --git a/api/routers/decisions.py b/api/routers/decisions.py index cd676d9..c44802d 100644 --- a/api/routers/decisions.py +++ b/api/routers/decisions.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime, timezone +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select @@ -7,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.decision import Decision, DecisionStatus, DecisionType -from api.schemas.decision import DecisionCreate, DecisionRead, DecisionUpdate +from api.models.progress_event import ProgressEvent +from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate router = APIRouter(prefix="/decisions", tags=["decisions"]) @@ -108,3 +110,87 @@ async def supersede_decision( 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 + ) diff --git a/api/schemas/decision.py b/api/schemas/decision.py index 6e52fe2..f02041f 100644 --- a/api/schemas/decision.py +++ b/api/schemas/decision.py @@ -26,6 +26,12 @@ class DecisionCreate(BaseModel): return self +class DecisionResolve(BaseModel): + rationale: str + decided_by: str + write_log: bool = True # append to DECISIONS.md in the registered project directory + + class DecisionUpdate(BaseModel): title: str | None = None description: str | None = None diff --git a/dashboard/src/index.md b/dashboard/src/index.md index b33c622..a289d50 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -209,12 +209,66 @@ const blocking = summary.blocking_decisions ?? []; if (blocking.length === 0) { display(html`
✓ No blocking decisions.
`); } else { - display(Inputs.table(blocking.map(d => ({ - Title: d.title, - Status: d.status, - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", - Escalated: d.escalation_note ? "⚠️" : "", - })))); + for (const d of blocking) { + const card = html`${d.description}
` : ""} + ${d.rationale ? html`Context: ${d.rationale}
` : ""} + ${d.escalation_note ? html`${d.escalation_note}
` : ""} +