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>
This commit is contained in:
2026-02-25 09:34:35 +01:00
parent 07742dd3f8
commit 533fecd6e1
3 changed files with 172 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
@@ -7,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.decision import Decision, DecisionStatus, DecisionType 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"]) router = APIRouter(prefix="/decisions", tags=["decisions"])
@@ -108,3 +110,87 @@ async def supersede_decision(
await session.commit() await session.commit()
await session.refresh(decision) await session.refresh(decision)
return 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
)

View File

@@ -26,6 +26,12 @@ class DecisionCreate(BaseModel):
return self 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): class DecisionUpdate(BaseModel):
title: str | None = None title: str | None = None
description: str | None = None description: str | None = None

View File

@@ -209,12 +209,66 @@ const blocking = summary.blocking_decisions ?? [];
if (blocking.length === 0) { if (blocking.length === 0) {
display(html`<p style="color:green">✓ No blocking decisions.</p>`); display(html`<p style="color:green">✓ No blocking decisions.</p>`);
} else { } else {
display(Inputs.table(blocking.map(d => ({ for (const d of blocking) {
Title: d.title, const card = html`<div class="dec-card ${d.escalation_note ? 'dec-escalated' : ''}">
Status: d.status, <div class="dec-header">
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", <span class="dec-title">${d.title}</span>
Escalated: d.escalation_note ? "⚠️" : "", <span class="dec-meta">
})))); ${d.escalation_note ? html`<span class="dec-warn-badge">⚠ escalated</span>` : ""}
${d.deadline ? html`<span>Due ${new Date(d.deadline).toLocaleDateString()}</span>` : ""}
</span>
</div>
${d.description ? html`<p class="dec-desc">${d.description}</p>` : ""}
${d.rationale ? html`<p class="dec-context"><strong>Context:</strong> ${d.rationale}</p>` : ""}
${d.escalation_note ? html`<p class="dec-context dec-warn-text">${d.escalation_note}</p>` : ""}
<details class="dec-resolve">
<summary>Resolve this decision →</summary>
<div class="dec-resolve-inner">
<label>Your decision &amp; rationale</label>
<textarea class="r-text" rows="4" placeholder="State the chosen option and your reasoning…"></textarea>
<label>Decided by</label>
<input class="r-by" type="text" value="Bernd">
<div class="dec-resolve-actions">
<button class="r-submit">Record &amp; close</button>
<span class="r-msg"></span>
</div>
</div>
</details>
</div>`;
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; } .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 { 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; } .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; }
</style> </style>