Vorgaenge in der WebUI: entscheiden und Stellungnahme anfordern (#689, Stufe 3)

- /api/cases (offen/alle), /api/cases/{id}/decide, /api/cases/{id}/statement, /api/decisions
- Ansicht "Vorgaenge": offene Faelle mit Verlauf je Ebene (Lauf-Links), Frist und
  "Mensch gerufen"; Reiter Beschluesse mit Reichweite; Zaehler in der Seitenleiste
- Konzept Abschnitt 5: Umsetzung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 09:53:25 +02:00
co-authored by Claude Opus 5.5
parent 36fe1ad2aa
commit 418a73015d
11 changed files with 600 additions and 2 deletions
+7
View File
@@ -61,6 +61,12 @@ public sealed class AppHost : IAsyncDisposable
public required ILoggerFactory LoggerFactory { get; init; }
public required ToolRegistry Tools { get; init; }
/// <summary>#689: Escalations, conflicts and decisions — read by the interfaces, acted on through the desk.</summary>
public Core.Cases.ICaseRepository? Cases { get; init; }
/// <summary>#689: The desk a human decides at; <c>null</c> without a task board.</summary>
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
/// <summary>
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
@@ -600,6 +606,7 @@ public sealed class AppHost : IAsyncDisposable
Board = board,
Usage = usage,
Transcripts = transcripts,
Cases = cases,
Insights = new RunInsights(audit, transcripts, board, staging,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
@@ -781,6 +781,68 @@ button.klein {
gap: 12px;
}
/* ─── #689: cases ─── */
.faelle-liste {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.fall-plaketten {
margin-bottom: 12px;
}
/* The two answers of the human wrap below the subject on a narrow screen instead of squeezing it. */
.fall-karte .karte-kopf {
flex-wrap: wrap;
gap: 8px;
}
.fall-karte .karte-kopf h2 {
flex: 1 1 240px;
}
.fall-beim-menschen {
border-left: 3px solid var(--warnung);
}
.fall-verlauf-titel {
margin: 16px 0 8px;
font-size: 14px;
}
.fall-verlauf {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.fall-eintrag {
padding: 8px 12px;
border-left: 2px solid var(--divider);
background: var(--bg);
}
.fall-eintrag-decided,
.fall-eintrag-handled {
border-left-color: var(--akzent, var(--divider));
}
.fall-eintrag-called {
border-left-color: var(--warnung);
}
.fall-eintrag-text {
margin-top: 4px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* #715: marks a tool that brings outside text in — it changes the agent's reporting line. */
.werkzeug-fremd {
display: inline-block;
@@ -37,6 +37,7 @@ import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js";
import { renderOrganisation } from "./views/organisation.js";
import { renderCases, holeMenschenfaelle } from "./views/cases.js";
// ─── 1. Routen registrieren ───
@@ -57,6 +58,7 @@ registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/cases", () => { verlasseAnsicht(); return renderCases(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
// #660: token usage sub-views and the prompt history
registriereRoute("/verbrauch/runs", () => { verlasseAnsicht(); return renderUsageRuns(); });
@@ -335,9 +337,19 @@ async function aktualisiereStagingZaehler() {
if (!holeAktiveInstanz()) {
const badge = document.getElementById("staging-badge");
if (badge) badge.hidden = true;
const faelle = document.getElementById("faelle-badge");
if (faelle) faelle.hidden = true;
return;
}
// #689: Cases that wait for a human are counted beside the approvals.
const faelleAnzahl = await holeMenschenfaelle();
const faelleBadge = document.getElementById("faelle-badge");
if (faelleBadge) {
faelleBadge.textContent = String(faelleAnzahl);
faelleBadge.hidden = faelleAnzahl === 0;
}
const anzahl = await holeWartendeAnzahl();
const badge = document.getElementById("staging-badge");
if (badge) {
@@ -222,6 +222,7 @@
"nav.group.system": "System",
"nav.instances": "Instanzen",
"nav.staging": "Freigaben",
"nav.cases": "Vorgänge",
"nav.logs": "Protokoll",
"nav.backup": "Sicherung",
"nav.settings": "Einstellungen",
@@ -851,5 +852,65 @@
"desktop.dialog.addService": "Dienst hinzufügen",
"desktop.dialog.restoreBackup": "Sicherung wiederherstellen",
"desktop.dialog.license": "Lizenz-Aktivierung",
"desktop.dialog.instancePicker": "Instanz auswählen"
"desktop.dialog.instancePicker": "Instanz auswählen",
"cases.title": "Vorgänge",
"cases.refresh": "Aktualisieren",
"cases.intro": "Eskalationen und Konflikte, die die Agenten nicht unter sich lösen konnten, und die Beschlüsse, die sie geschlossen haben. Was bis zur Frist niemand löst, landet hier.",
"cases.tabOpen": "Offen",
"cases.tabAll": "Alle",
"cases.tabDecisions": "Beschlüsse",
"cases.none": "Keine offenen Vorgänge",
"cases.noneHint": "Nichts wartet auf eine Entscheidung.",
"cases.noDecisions": "Noch keine Beschlüsse",
"cases.noDecisionsHint": "Jeder entschiedene Vorgang hinterlässt hier einen Beschluss. Er gilt für den Bereich, für den er gefasst wurde, und wird gezeigt, bevor jemand dieselbe Frage erneut stellt.",
"cases.loadFailed": "Vorgänge konnten nicht geladen werden",
"cases.kindEscalation": "Eskalation",
"cases.kindConflict": "Konflikt",
"cases.status.open": "offen",
"cases.status.decided": "entschieden",
"cases.status.handled": "erledigt",
"cases.overdue": "Frist überschritten",
"cases.called": "Mensch gerufen",
"cases.id": "ID",
"cases.openedBy": "Eröffnet von",
"cases.holder": "Liegt bei",
"cases.human": "dir (Mensch)",
"cases.humanAuthor": "Mensch",
"cases.deadline": "Frist",
"cases.level": "Weitergereicht",
"cases.parties": "Seiten",
"cases.informed": "Vermerkt, nicht befragt",
"cases.waitingFor": "Wartet auf",
"cases.origin": "Herkunft",
"cases.trail": "Was jede Ebene gesagt hat",
"cases.entry.opened": "eröffnet",
"cases.entry.joined": "angeschlossen",
"cases.entry.forwarded": "weitergereicht",
"cases.entry.position": "Standpunkt",
"cases.entry.statement": "Stellungnahme",
"cases.entry.statement-requested": "Stellungnahme angefordert",
"cases.entry.called": "Mensch gerufen",
"cases.entry.decided": "entschieden",
"cases.entry.handled": "erledigt",
"cases.entry.note": "Vermerk",
"cases.decide": "Entscheiden",
"cases.decideTitle": "Vorgang {id} entscheiden",
"cases.decideHint": "Deine Entscheidung gilt für die ganze Instanz. Jeder Agent, der dieselbe Frage stellt, bekommt sie vorher angezeigt. Alle, die den Vorgang eröffnet haben oder daran beteiligt sind, erfahren davon.",
"cases.answer": "Entscheidung",
"cases.answerPlaceholder": "Was ab jetzt gilt",
"cases.reasoning": "Begründung",
"cases.reasoningPlaceholder": "Warum. Ohne Begründung lässt sich eine Entscheidung unten nicht anwenden",
"cases.decided": "Beschluss gespeichert.",
"cases.requestStatement": "Stellungnahme anfordern",
"cases.statementTitle": "Stellungnahme zu Vorgang {id} anfordern",
"cases.statementFrom": "Von",
"cases.question": "Frage",
"cases.questionPlaceholder": "Was musst du wissen?",
"cases.statementHint": "Der Agent bekommt eine Aufgabe mit genau dieser Frage und einer eigenen Frist. Der Vorgang bleibt bei dir.",
"cases.statementRequested": "Stellungnahme angefordert.",
"cases.scope": "Gilt für",
"cases.scopeAll": "die ganze Instanz",
"cases.decidedBy": "Entschieden von",
"cases.failed": "Fehlgeschlagen: {error}"
}
@@ -222,6 +222,7 @@
"nav.group.system": "System",
"nav.instances": "Instances",
"nav.staging": "Approvals",
"nav.cases": "Cases",
"nav.logs": "Logs",
"nav.backup": "Backup",
"nav.settings": "Settings",
@@ -851,5 +852,65 @@
"desktop.dialog.addService": "Add Service",
"desktop.dialog.restoreBackup": "Restore Backup",
"desktop.dialog.license": "License Activation",
"desktop.dialog.instancePicker": "Choose Instance"
"desktop.dialog.instancePicker": "Choose Instance",
"cases.title": "Cases",
"cases.refresh": "Refresh",
"cases.intro": "Escalations and conflicts the agents could not solve among themselves, and the decisions that closed them. A case nobody solves by its deadline comes here.",
"cases.tabOpen": "Open",
"cases.tabAll": "All",
"cases.tabDecisions": "Decisions",
"cases.none": "No open cases",
"cases.noneHint": "Nothing waits for a decision.",
"cases.noDecisions": "No decisions yet",
"cases.noDecisionsHint": "Every decided case leaves a decision here. It binds the area it was made for and is shown before anyone asks the same again.",
"cases.loadFailed": "Cases could not be loaded",
"cases.kindEscalation": "Escalation",
"cases.kindConflict": "Conflict",
"cases.status.open": "open",
"cases.status.decided": "decided",
"cases.status.handled": "handled",
"cases.overdue": "past deadline",
"cases.called": "human called",
"cases.id": "ID",
"cases.openedBy": "Opened by",
"cases.holder": "With",
"cases.human": "you (human)",
"cases.humanAuthor": "human",
"cases.deadline": "Deadline",
"cases.level": "Passed on",
"cases.parties": "Sides",
"cases.informed": "Told, not asked",
"cases.waitingFor": "Waiting for",
"cases.origin": "Arose from",
"cases.trail": "What each level said",
"cases.entry.opened": "opened",
"cases.entry.joined": "joined",
"cases.entry.forwarded": "passed on",
"cases.entry.position": "position",
"cases.entry.statement": "statement",
"cases.entry.statement-requested": "statement requested",
"cases.entry.called": "human called",
"cases.entry.decided": "decided",
"cases.entry.handled": "handled",
"cases.entry.note": "note",
"cases.decide": "Decide",
"cases.decideTitle": "Decide case {id}",
"cases.decideHint": "Your decision binds the whole instance and is shown to every agent that raises the same question. Everyone who raised it or is a side of it hears it.",
"cases.answer": "Decision",
"cases.answerPlaceholder": "What holds from now on",
"cases.reasoning": "Reasoning",
"cases.reasoningPlaceholder": "Why — without reasons a decision cannot be applied below",
"cases.decided": "Decision recorded.",
"cases.requestStatement": "Request statement",
"cases.statementTitle": "Request a statement for case {id}",
"cases.statementFrom": "From",
"cases.question": "Question",
"cases.questionPlaceholder": "What do you need to know?",
"cases.statementHint": "The agent gets a task with exactly this question and its own deadline. The case stays with you.",
"cases.statementRequested": "Statement requested.",
"cases.scope": "Binds",
"cases.scopeAll": "the whole instance",
"cases.decidedBy": "Decided by",
"cases.failed": "Failed: {error}"
}
@@ -124,6 +124,13 @@
<span class="nav-label" data-i18n="nav.staging">Approvals</span>
<span id="staging-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/cases" class="nav-link" title="Cases" data-i18n-title="nav.cases">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 3v6 M8 7l4-4 4 4 M4 13h16v8H4z M9 17h6"/>
</svg>
<span class="nav-label" data-i18n="nav.cases">Cases</span>
<span id="faelle-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/protokoll" class="nav-link" title="Logs" data-i18n-title="nav.logs">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
@@ -0,0 +1,230 @@
// ClawdDotNet — cases: escalations, conflicts and the decisions that close them (#689)
//
// A case nobody solves in time comes to a human. Here the human reads what each level said —
// appended, never replaced (#697) — decides with reasons, or asks one side for a statement.
// The decisions tab is the organisation's memory: what was decided, why, and how far it binds.
//
// Everything in a trail was written by agents and may carry foreign text. It is only ever
// inserted via textContent.
import { api } from "../modules/api.js";
import { t } from "../modules/i18n.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
import { laufLink } from "./run-detail.js";
let reiter = "open";
/** Open cases a human holds — for the badge in the sidebar. */
export async function holeMenschenfaelle() {
try {
const liste = await api("/cases");
return Array.isArray(liste) ? liste.filter((c) => !c.holder).length : 0;
} catch {
return 0;
}
}
export async function renderCases() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" },
erstelleElement("h1", { text: t("cases.title") }),
erstelleElement("button", { klasse: "schaltflaeche flach", onclick: laden },
erstelleIcon(PFADE.aktualisieren, 16),
erstelleElement("span", { text: ` ${t("cases.refresh")}` })));
const leiste = erstelleElement("div", { klasse: "reiter-leiste" });
for (const [wert, schluessel] of [["open", "cases.tabOpen"], ["all", "cases.tabAll"], ["decisions", "cases.tabDecisions"]]) {
leiste.append(erstelleElement("button", {
klasse: `reiter-schaltflaeche${wert === reiter ? " aktiv" : ""}`,
text: t(schluessel),
dataset: { wert },
onclick: () => {
reiter = wert;
for (const b of leiste.children) b.classList.toggle("aktiv", b.dataset.wert === wert);
laden();
}
}));
}
const hinweis = erstelleElement("p", { klasse: "gedaempft", text: t("cases.intro") });
const bereich = erstelleElement("div", { id: "faelle-bereich", klasse: "faelle-liste" });
container.replaceChildren(erstelleElement("div", { klasse: "faelle-ansicht" }, kopf, hinweis, leiste, bereich));
await laden();
}
async function laden() {
const bereich = document.getElementById("faelle-bereich");
if (!bereich) return;
bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
try {
if (reiter === "decisions") {
const liste = await api("/decisions");
bereich.replaceChildren(...(liste.length === 0
? [leer(t("cases.noDecisions"), t("cases.noDecisionsHint"))]
: liste.map(beschlussKarte)));
return;
}
const liste = await api(reiter === "all" ? "/cases?all=true" : "/cases");
// What waits for a human first, then the most urgent deadline.
liste.sort((a, b) => (a.holder ? 1 : 0) - (b.holder ? 1 : 0) || new Date(a.deadlineUtc) - new Date(b.deadlineUtc));
bereich.replaceChildren(...(liste.length === 0
? [leer(t("cases.none"), t("cases.noneHint"))]
: liste.map(fallKarte)));
} catch (err) {
bereich.replaceChildren(leer(t("cases.loadFailed"), err.message));
}
}
function leer(titel, text) {
return erstelleKarte(titel, [erstelleElement("p", { klasse: "gedaempft", text })]);
}
function fallKarte(fall) {
const offen = fall.status === "open";
const beimMenschen = offen && !fall.holder;
const ueberfaellig = offen && new Date(fall.deadlineUtc) < new Date();
const plaketten = erstelleElement("div", { klasse: "gruppe-horizontal fall-plaketten" },
erstellePlakette(t(fall.kind === "conflict" ? "cases.kindConflict" : "cases.kindEscalation"), fall.kind === "conflict" ? "warnung" : "neutral"),
erstellePlakette(t(`cases.status.${fall.status}`), offen ? (beimMenschen ? "gefahr" : "neutral") : "erfolg"));
if (ueberfaellig && !beimMenschen) plaketten.append(erstellePlakette(t("cases.overdue"), "gefahr"));
if (fall.humanCalledAt) plaketten.append(erstellePlakette(t("cases.called"), "warnung"));
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
if (beimMenschen) {
aktionen.append(
erstelleElement("button", { klasse: "schaltflaeche primaer klein", onclick: () => entscheiden(fall) },
erstelleIcon(PFADE.haken, 14), erstelleElement("span", { text: ` ${t("cases.decide")}` })),
erstelleElement("button", { klasse: "schaltflaeche flach klein", onclick: () => stellungnahme(fall) },
erstelleElement("span", { text: t("cases.requestStatement") })));
}
const daten = erstelleElement("dl", { klasse: "datenliste" });
const zeile = (bez, wert) => daten.append(
erstelleElement("dt", { text: t(bez) }),
wert instanceof Node ? erstelleElement("dd", {}, wert) : erstelleElement("dd", { text: wert ?? "—" }));
zeile("cases.id", fall.id);
zeile("cases.openedBy", `${fall.openedBy} · ${formatiereDatumZeit(fall.openedAt)}`);
zeile("cases.holder", fall.holder || t("cases.human"));
zeile("cases.deadline", formatiereDatumZeit(fall.deadlineUtc));
if (fall.level > 0) zeile("cases.level", String(fall.level));
if (fall.parties.length > 0) zeile("cases.parties", fall.parties.join(" · "));
if (fall.informed.length > 0) zeile("cases.informed", fall.informed.join(", "));
if (fall.waitingFor.length > 0) zeile("cases.waitingFor", fall.waitingFor.join(", "));
if (fall.originTaskId || fall.originRunId) {
const herkunft = erstelleElement("span", { text: fall.originTaskId ? `${fall.originTaskId} ` : "" });
if (fall.originRunId) herkunft.append(laufLink(fall.originRunId, t("tasks.originRun")));
zeile("cases.origin", herkunft);
}
const verlauf = erstelleElement("ol", { klasse: "fall-verlauf" });
for (const eintrag of fall.trail) {
const kopfzeile = erstelleElement("div", { klasse: "fall-eintrag-kopf gedaempft text-klein" },
`${formatiereDatumZeit(eintrag.at)} · ${eintrag.author === "human" ? t("cases.humanAuthor") : eintrag.author} · ${t(`cases.entry.${eintrag.kind}`)}`);
if (eintrag.runId) kopfzeile.append(" · ", laufLink(eintrag.runId, t("tasks.originRun")));
verlauf.append(erstelleElement("li", { klasse: `fall-eintrag fall-eintrag-${eintrag.kind}` },
kopfzeile,
erstelleElement("div", { klasse: "fall-eintrag-text", text: eintrag.text })));
}
return erstelleKarte(fall.subject, [
plaketten,
daten,
erstelleElement("h3", { klasse: "fall-verlauf-titel", text: t("cases.trail") }),
verlauf
], { aktionen, klasse: beimMenschen ? "fall-karte fall-beim-menschen" : "fall-karte" });
}
function beschlussKarte(beschluss) {
const daten = erstelleElement("dl", { klasse: "datenliste" });
const zeile = (bez, wert) => daten.append(erstelleElement("dt", { text: t(bez) }), erstelleElement("dd", { text: wert ?? "—" }));
zeile("cases.answer", beschluss.answer);
zeile("cases.reasoning", beschluss.reasoning);
zeile("cases.scope", beschluss.scopePath || t("cases.scopeAll"));
zeile("cases.decidedBy", `${beschluss.decidedBy === "human" ? t("cases.humanAuthor") : beschluss.decidedBy} · ${formatiereDatumZeit(beschluss.decidedAt)}`);
zeile("cases.id", `${beschluss.id} · ${beschluss.caseId}`);
return erstelleKarte(beschluss.subject, [daten], { klasse: "fall-karte" });
}
// ─── the human's two answers ───
function textfeld(platzhalter) {
return erstelleElement("textarea", { rows: 4, required: true, placeholder: platzhalter });
}
function entscheiden(fall) {
const antwort = textfeld(t("cases.answerPlaceholder"));
const begruendung = textfeld(t("cases.reasoningPlaceholder"));
const formular = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("cases.answer") }), antwort,
erstelleElement("label", { text: t("cases.reasoning") }), begruendung,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.decideHint") }));
absenden(t("cases.decideTitle", { id: fall.id }), formular, t("cases.decide"), async () => {
await api(`/cases/${encodeURIComponent(fall.id)}/decide`, {
method: "POST",
body: { answer: antwort.value.trim(), reasoning: begruendung.value.trim() }
});
zeigeMeldung(t("cases.decided"), "info");
});
}
async function stellungnahme(fall) {
const auswahl = erstelleElement("select", { required: true });
let agenten = [];
try { agenten = await api("/agents"); } catch { /* the list stays empty; the server refuses an unknown agent */ }
// The sides and the levels in between first — they are who a statement is usually asked of.
const nahe = new Set([...fall.parties, ...fall.informed, fall.openedBy]);
agenten.sort((a, b) => (nahe.has(b.agentId) ? 1 : 0) - (nahe.has(a.agentId) ? 1 : 0) || a.agentId.localeCompare(b.agentId));
for (const a of agenten) auswahl.append(erstelleElement("option", { value: a.agentId, text: a.displayName && a.displayName !== a.agentId ? `${a.agentId} (${a.displayName})` : a.agentId }));
const frage = textfeld(t("cases.questionPlaceholder"));
const formular = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("cases.statementFrom") }), auswahl,
erstelleElement("label", { text: t("cases.question") }), frage,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.statementHint") }));
absenden(t("cases.statementTitle", { id: fall.id }), formular, t("cases.requestStatement"), async () => {
await api(`/cases/${encodeURIComponent(fall.id)}/statement`, {
method: "POST",
body: { agentId: auswahl.value, question: frage.value.trim() }
});
zeigeMeldung(t("cases.statementRequested"), "info");
});
}
function absenden(titel, formular, knopfText, aktion) {
const abbrechen = erstelleElement("button", { type: "button", klasse: "schaltflaeche flach", text: t("common.cancel"), onclick: () => modal.schliessen() });
const senden = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: knopfText });
const modal = erstelleModal(titel, formular, [abbrechen, senden]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
senden.disabled = true;
try {
await aktion();
modal.schliessen();
await laden();
} catch (err) {
zeigeMeldung(t("cases.failed", { error: err.message }), "gefahr");
} finally {
senden.disabled = false;
}
});
modal.oeffnen();
}
+43
View File
@@ -0,0 +1,43 @@
namespace ClawdDotNet.Web.Contracts;
/// <summary>#689: A case — escalation or conflict — with everything each level said.</summary>
public sealed record CaseDto(
string Id,
string Kind,
string Status,
string Subject,
string OpenedBy,
DateTime OpenedAt,
DateTime DeadlineUtc,
DateTime? HumanCalledAt,
// null: a human holds it.
string? Holder,
string HolderTaskId,
int Level,
string OriginTaskId,
string OriginRunId,
IReadOnlyList<string> Parties,
IReadOnlyList<string> Informed,
IReadOnlyList<string> WaitingFor,
IReadOnlyList<CaseEntryDto> Trail,
string? DecisionId);
public sealed record CaseEntryDto(DateTime At, string Author, string Kind, string Text, string? RunId);
public sealed record DecisionDto(
string Id,
string Subject,
string Question,
string Answer,
string Reasoning,
string DecidedBy,
DateTime DecidedAt,
string? ScopeUnitId,
// "Purchasing / Team A"; null: binds the whole instance.
string? ScopePath,
string CaseId,
string? RunId);
public sealed record CaseDecisionInputDto(string? Answer, string? Reasoning);
public sealed record CaseStatementInputDto(string? AgentId, string? Question);
@@ -32,6 +32,7 @@ internal static class ApiEndpoints
api.MapAnalytics();
api.MapLogs();
api.MapOrganisation();
api.MapCases();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
@@ -0,0 +1,71 @@
using ClawdDotNet.App;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// #689: Cases and decisions for the human. Reading is the repository's, deciding the desk's —
/// the same rules as for an agent, with the human as the one holder above everyone.
/// </summary>
internal static class CaseEndpoints
{
public static void MapCases(this RouteGroupBuilder api)
{
var cases = api.MapGroup("/cases");
cases.MapGet("/", async (AppHost host, bool? all, CancellationToken ct) =>
{
if (host.Cases is not { } repo)
return Results.Ok(Array.Empty<CaseDto>());
var list = all == true ? await repo.ListRecentAsync(200, ct) : await repo.ListOpenAsync(ct);
return Results.Ok(list.OrderByDescending(c => c.OpenedAt).Select(ToDto).ToList());
});
cases.MapGet("/{caseId}", async (AppHost host, string caseId, CancellationToken ct) =>
host.Cases is { } repo && await repo.GetAsync(caseId, ct) is { } record
? Results.Ok(ToDto(record))
: Results.NotFound(new OutcomeDto(false, $"No case '{caseId}'.")));
cases.MapPost("/{caseId}/decide", async (AppHost host, string caseId, CaseDecisionInputDto input, CancellationToken ct) =>
{
if (host.CaseDesk is not { } desk)
return Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance."));
var outcome = await desk.DecideAsync(caseId, CaseRules.Human, input.Answer ?? "", input.Reasoning ?? "", null, ct);
return Respond(outcome);
});
cases.MapPost("/{caseId}/statement", async (AppHost host, string caseId, CaseStatementInputDto input, CancellationToken ct) =>
{
if (host.CaseDesk is not { } desk)
return Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance."));
var outcome = await desk.RequestStatementAsync(caseId, input.AgentId ?? "", input.Question ?? "", ct);
return Respond(outcome);
});
api.MapGet("/decisions", async (AppHost host, CancellationToken ct) =>
{
if (host.Cases is not { } repo)
return Results.Ok(Array.Empty<DecisionDto>());
var org = host.Organisation.Current();
return Results.Ok((await repo.ListDecisionsAsync(ct)).Select(d => new DecisionDto(
d.Id, d.Subject, d.Question, d.Answer, d.Reasoning, d.DecidedBy, d.DecidedAt,
d.ScopeUnitId, d.ScopeUnitId is null ? null : org.PathOf(d.ScopeUnitId), d.CaseId, d.RunId)).ToList());
});
}
private static IResult Respond(CaseOutcome outcome)
=> outcome.Ok
? Results.Ok(new OutcomeDto(true, outcome.Message))
: Results.BadRequest(new OutcomeDto(false, outcome.Message));
private static CaseDto ToDto(CaseRecord c) => new(
c.Id, c.Kind.ToString().ToLowerInvariant(), c.Status.ToString().ToLowerInvariant(), c.Subject,
c.OpenedBy, c.OpenedAt, c.DeadlineUtc, c.HumanCalledAt, c.HeldByHuman ? null : c.Holder, c.HolderTaskId,
c.Level, c.OriginTaskId, c.OriginRunId, c.Parties, c.Informed, c.PendingStatements.Keys.ToList(),
c.Trail.Select(e => new CaseEntryDto(e.At, e.Author, e.Kind, e.Text, e.RunId)).ToList(),
c.DecisionId);
}