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 { .status-control-message.status-control-ok {
color: #16a34a; color: #16a34a;
} }
.status-control-message.status-control-review {
color: #b45309;
}
`; `;
document.head.append(style); document.head.append(style);
} }
@@ -52,12 +55,6 @@ function labelForStatus(status) {
return String(status ?? "").replace(/_/g, " "); 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) { async function readError(response) {
try { try {
const body = await response.json(); 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({ export function statusControl({
entity, entity,
type, type,
@@ -100,13 +122,14 @@ export function statusControl({
message.textContent = text; message.textContent = text;
message.classList.toggle("status-control-error", kind === "error"); message.classList.toggle("status-control-error", kind === "error");
message.classList.toggle("status-control-ok", kind === "ok"); message.classList.toggle("status-control-ok", kind === "ok");
message.classList.toggle("status-control-review", kind === "review");
} }
select.addEventListener("change", async () => { select.addEventListener("change", async () => {
const nextStatus = select.value; const nextStatus = select.value;
if (nextStatus === currentStatus) return; if (nextStatus === currentStatus) return;
const payload = {status: nextStatus}; let blockingReason = null;
if (type === "task" && nextStatus === "blocked") { if (type === "task" && nextStatus === "blocked") {
const existingReason = entity?.blocking_reason ?? ""; const existingReason = entity?.blocking_reason ?? "";
const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:"); const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:");
@@ -115,12 +138,12 @@ export function statusControl({
setMessage("unchanged"); setMessage("unchanged");
return; return;
} }
payload.blocking_reason = reason; blockingReason = reason;
} }
if (type === "task" && nextStatus === "done" && currentStatus !== "done") { if (type === "task" && nextStatus === "done" && currentStatus !== "done") {
const ok = window.confirm( 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) { if (!ok) {
select.value = currentStatus; select.value = currentStatus;
@@ -132,20 +155,20 @@ export function statusControl({
select.disabled = true; select.disabled = true;
setMessage("saving"); setMessage("saving");
try { try {
const response = await apiFetch(endpointFor(type, entity.id), { const result = await reconcileStatusChange({entity, type, nextStatus, blockingReason});
method: "PATCH", const messageResult = messageForReconciliation(result);
headers: {"Content-Type": "application/json"}, if (result.write_through_result === "applied") {
body: JSON.stringify(payload), Object.assign(entity, {status: result.target_status});
}); if (blockingReason !== null) entity.blocking_reason = blockingReason;
if (!response.ok) throw new Error(await readError(response)); currentStatus = result.target_status;
const updated = await response.json(); select.value = currentStatus;
Object.assign(entity, updated); if (typeof onSaved === "function") onSaved(entity, result);
currentStatus = updated.status; } else {
select.value = currentStatus; select.value = currentStatus;
setMessage("saved", "ok"); }
if (typeof onSaved === "function") onSaved(updated); setMessage(messageResult.text, messageResult.kind);
setTimeout(() => { setTimeout(() => {
if (message.textContent === "saved") setMessage(""); if (message.textContent === messageResult.text) setMessage("");
}, 2200); }, 2200);
} catch (error) { } catch (error) {
select.value = currentStatus; select.value = currentStatus;

View File

@@ -138,7 +138,7 @@ when known.
```task ```task
id: STATE-WP-0048-T05 id: STATE-WP-0048-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "04025e9f-b1cc-4b73-b95a-2a53bad6b360" 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 Done when a dashboard user can tell whether their state change has reached the
repo file or still needs reconciliation. 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 ## T06 - Add Conflict Handling
```task ```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 Progress 2026-05-23: added API tests for classify-only responses, safe
write-through, missing-file deferral, and human-confirmation message creation. 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 ## Acceptance Criteria
- Dashboard state changes never create silent DB/file divergence. - Dashboard state changes never create silent DB/file divergence.