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.title} + + ${d.escalation_note ? html`⚠ escalated` : ""} + ${d.deadline ? html`Due ${new Date(d.deadline).toLocaleDateString()}` : ""} + +
+ ${d.description ? html`

${d.description}

` : ""} + ${d.rationale ? html`

Context: ${d.rationale}

` : ""} + ${d.escalation_note ? html`

${d.escalation_note}

` : ""} +
+ Resolve this decision → +
+ + + + +
+ + +
+
+
+
`; + + const btn = card.querySelector(".r-submit"); + const msg = card.querySelector(".r-msg"); + const det = card.querySelector(".dec-resolve"); + + btn.addEventListener("click", async () => { + const rationale = card.querySelector(".r-text").value.trim(); + const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd"; + if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; } + btn.disabled = true; btn.textContent = "Saving…"; + try { + const r = await fetch(`${API}/decisions/${d.id}/resolve`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({rationale, decided_by: decidedBy}), + }); + if (r.ok) { + det.open = false; + det.querySelector("summary").textContent = "✓ Resolved — DECISIONS.md written, updates in next poll"; + det.querySelector("summary").style.color = "green"; + card.style.opacity = "0.55"; + } else { + const err = await r.json().catch(() => ({})); + msg.textContent = `Error ${r.status}: ${err.detail ?? "unknown"}`; + btn.disabled = false; btn.textContent = "Record & close"; + } + } catch (e) { + msg.textContent = `Network error: ${e.message}`; + btn.disabled = false; btn.textContent = "Record & close"; + } + }); + + display(card); + } } ``` @@ -253,4 +307,23 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } .hint-box { background: var(--theme-background-alt); border-left: 3px solid steelblue; border-radius: 4px; padding: 0.75rem 1rem; margin-top: 0.75rem; font-size: 0.9rem; } .hint-box code { background: var(--theme-background); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; } +.dec-card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; border-left: 4px solid steelblue; } +.dec-card.dec-escalated { border-left-color: orange; } +.dec-header { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.5rem; } +.dec-title { font-weight: 600; font-size: 1rem; } +.dec-meta { font-size: 0.8rem; color: gray; display: flex; gap: 0.5rem; align-items: center; } +.dec-warn-badge { background: orange; color: white; border-radius: 3px; padding: 0.1rem 0.35rem; font-size: 0.75rem; } +.dec-desc { font-size: 0.9rem; margin: 0.4rem 0 0.25rem; white-space: pre-wrap; line-height: 1.5; } +.dec-context { font-size: 0.85rem; color: gray; margin: 0.25rem 0; } +.dec-warn-text { color: #b45309; } +.dec-resolve { margin-top: 0.75rem; } +.dec-resolve summary { cursor: pointer; font-size: 0.85rem; color: steelblue; user-select: none; } +.dec-resolve-inner { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.6rem; } +.dec-resolve-inner label { font-size: 0.8rem; font-weight: 600; color: gray; } +.dec-resolve-inner textarea { width: 100%; box-sizing: border-box; padding: 0.4rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); font-family: inherit; font-size: 0.875rem; resize: vertical; } +.dec-resolve-inner input[type=text] { width: 220px; padding: 0.3rem 0.5rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); font-family: inherit; font-size: 0.875rem; } +.dec-resolve-actions { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.25rem; } +.dec-resolve-actions button { padding: 0.35rem 0.9rem; border-radius: 4px; border: none; background: steelblue; color: white; cursor: pointer; font-size: 0.875rem; } +.dec-resolve-actions button:disabled { opacity: 0.5; cursor: default; } +.r-msg { font-size: 0.8rem; color: #b45309; }