Route dashboard status changes through reconciliation

This commit is contained in:
2026-05-23 18:00:57 +02:00
parent d4bcfa92d5
commit 430923c857
2 changed files with 56 additions and 23 deletions

View File

@@ -44,6 +44,9 @@ function ensureStyles() {
.status-control-message.status-control-ok {
color: #16a34a;
}
.status-control-message.status-control-review {
color: #b45309;
}
`;
document.head.append(style);
}
@@ -52,12 +55,6 @@ function labelForStatus(status) {
return String(status ?? "").replace(/_/g, " ");
}
function endpointFor(type, id) {
if (type === "task") return `/tasks/${id}`;
if (type === "workstream") return `/workstreams/${id}`;
throw new Error(`Unsupported status-control type: ${type}`);
}
async function readError(response) {
try {
const body = await response.json();
@@ -68,6 +65,31 @@ async function readError(response) {
}
}
async function reconcileStatusChange({entity, type, nextStatus, blockingReason = null}) {
const response = await apiFetch("/reconciliation/state-change", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
target_type: type,
target_id: entity.id,
target_status: nextStatus,
actor: "dashboard",
intent: `${type} status change via dashboard`,
blocking_reason: blockingReason,
apply: true,
}),
});
if (!response.ok) throw new Error(await readError(response));
return response.json();
}
function messageForReconciliation(result) {
if (result.write_through_result === "applied") return {text: "synced", kind: "ok"};
if (result.reconciliation_class === "human_confirmation") return {text: "needs review", kind: "review"};
if (result.reconciliation_class === "deferred") return {text: "queued", kind: ""};
return {text: "unchanged", kind: ""};
}
export function statusControl({
entity,
type,
@@ -100,13 +122,14 @@ export function statusControl({
message.textContent = text;
message.classList.toggle("status-control-error", kind === "error");
message.classList.toggle("status-control-ok", kind === "ok");
message.classList.toggle("status-control-review", kind === "review");
}
select.addEventListener("change", async () => {
const nextStatus = select.value;
if (nextStatus === currentStatus) return;
const payload = {status: nextStatus};
let blockingReason = null;
if (type === "task" && nextStatus === "blocked") {
const existingReason = entity?.blocking_reason ?? "";
const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:");
@@ -115,12 +138,12 @@ export function statusControl({
setMessage("unchanged");
return;
}
payload.blocking_reason = reason;
blockingReason = reason;
}
if (type === "task" && nextStatus === "done" && currentStatus !== "done") {
const ok = window.confirm(
"Mark this task done? Without token fields, State Hub records a heuristic token event."
"Mark this task done? State Hub will reconcile the workplan file before updating cached state."
);
if (!ok) {
select.value = currentStatus;
@@ -132,20 +155,20 @@ export function statusControl({
select.disabled = true;
setMessage("saving");
try {
const response = await apiFetch(endpointFor(type, entity.id), {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await readError(response));
const updated = await response.json();
Object.assign(entity, updated);
currentStatus = updated.status;
select.value = currentStatus;
setMessage("saved", "ok");
if (typeof onSaved === "function") onSaved(updated);
const result = await reconcileStatusChange({entity, type, nextStatus, blockingReason});
const messageResult = messageForReconciliation(result);
if (result.write_through_result === "applied") {
Object.assign(entity, {status: result.target_status});
if (blockingReason !== null) entity.blocking_reason = blockingReason;
currentStatus = result.target_status;
select.value = currentStatus;
if (typeof onSaved === "function") onSaved(entity, result);
} else {
select.value = currentStatus;
}
setMessage(messageResult.text, messageResult.kind);
setTimeout(() => {
if (message.textContent === "saved") setMessage("");
if (message.textContent === messageResult.text) setMessage("");
}, 2200);
} catch (error) {
select.value = currentStatus;

View File

@@ -138,7 +138,7 @@ when known.
```task
id: STATE-WP-0048-T05
status: todo
status: done
priority: medium
state_hub_task_id: "04025e9f-b1cc-4b73-b95a-2a53bad6b360"
```
@@ -149,6 +149,12 @@ on human review, or out of sync.
Done when a dashboard user can tell whether their state change has reached the
repo file or still needs reconciliation.
Result 2026-05-23: dashboard status controls now submit state changes through
`POST /reconciliation/state-change` with `apply: true` instead of direct DB
patches. The control reports `synced` when the repo file and cached DB state
were updated, `queued` for deferred reconciliation, and `needs review` for
human-confirmation cases.
## T06 - Add Conflict Handling
```task
@@ -181,6 +187,10 @@ Done when UI state changes are covered as first-class ADR-001 workflows.
Progress 2026-05-23: added API tests for classify-only responses, safe
write-through, missing-file deferral, and human-confirmation message creation.
Progress 2026-05-23: routed dashboard status controls through the
reconciliation API so UI-originated changes exercise the same write-through and
deferred-record path as API clients.
## Acceptance Criteria
- Dashboard state changes never create silent DB/file divergence.