Harden reconciliation conflict handling

This commit is contained in:
2026-05-23 18:18:44 +02:00
parent 7831673820
commit 706b360736
8 changed files with 445 additions and 22 deletions

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "observable preview",
"test": "node --test test/*.test.mjs",
"build": "observable build",
"clean": "rm -rf dist"
},

View File

@@ -65,7 +65,13 @@ async function readError(response) {
}
}
async function reconcileStatusChange({entity, type, nextStatus, blockingReason = null}) {
async function reconcileStatusChange({
entity,
type,
currentStatus,
nextStatus,
blockingReason = null,
}) {
const response = await apiFetch("/reconciliation/state-change", {
method: "POST",
headers: {"Content-Type": "application/json"},
@@ -75,6 +81,7 @@ async function reconcileStatusChange({entity, type, nextStatus, blockingReason =
target_status: nextStatus,
actor: "dashboard",
intent: `${type} status change via dashboard`,
expected_current_status: currentStatus,
blocking_reason: blockingReason,
apply: true,
}),
@@ -84,6 +91,7 @@ async function reconcileStatusChange({entity, type, nextStatus, blockingReason =
}
function messageForReconciliation(result) {
if (result.conflict) return {text: "out of sync", kind: "review"};
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: ""};
@@ -155,7 +163,13 @@ export function statusControl({
select.disabled = true;
setMessage("saving");
try {
const result = await reconcileStatusChange({entity, type, nextStatus, blockingReason});
const result = await reconcileStatusChange({
entity,
type,
currentStatus,
nextStatus,
blockingReason,
});
const messageResult = messageForReconciliation(result);
if (result.write_through_result === "applied") {
Object.assign(entity, {status: result.target_status});

View File

@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import test from "node:test";
class FakeClassList {
constructor() {
this.values = new Set();
}
toggle(name, enabled) {
if (enabled) {
this.values.add(name);
} else {
this.values.delete(name);
}
}
}
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.listeners = {};
this.classList = new FakeClassList();
this.className = "";
this.textContent = "";
this.value = "";
this.disabled = false;
}
setAttribute(name, value) {
this[name] = value;
}
append(...children) {
this.children.push(...children);
}
addEventListener(type, listener) {
this.listeners[type] = listener;
}
}
function installDom() {
const styles = new Map();
globalThis.document = {
head: {
append(element) {
if (element.id) styles.set(element.id, element);
},
},
getElementById(id) {
return styles.get(id) ?? null;
},
createElement(tagName) {
return new FakeElement(tagName);
},
};
globalThis.window = {
confirm: () => true,
prompt: () => "",
};
globalThis.setTimeout = () => 0;
}
installDom();
const {statusControl} = await import("../src/components/status-control.js");
function okResponse(overrides = {}) {
return {
target_type: "task",
target_id: "00000000-0000-0000-0000-000000000001",
actor: "dashboard",
current_status: "todo",
target_status: "in_progress",
file_backed: true,
archived_file: false,
task_linked: true,
reconciliation_class: "write_through",
reason: "task status can be represented in the workplan task block",
follow_up: "patch task block status and sync the DB from file",
write_through_result: "applied",
workplan_path: "workplans/STATE-WP-9999-demo.md",
reconciliation_record_id: null,
conflict: false,
...overrides,
};
}
test("status control posts dashboard changes through reconciliation", async () => {
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push({url, options, body: JSON.parse(options.body)});
return {
ok: true,
json: async () => okResponse(),
};
};
const entity = {id: "00000000-0000-0000-0000-000000000001", status: "todo"};
let saved = null;
const root = statusControl({
entity,
type: "task",
statuses: ["todo", "in_progress"],
onSaved: (updated, result) => {
saved = {updated, result};
},
});
const [select, message] = root.children;
select.value = "in_progress";
await select.listeners.change();
assert.equal(requests.length, 1);
assert.equal(requests[0].url, "http://127.0.0.1:8000/reconciliation/state-change");
assert.equal(requests[0].body.target_type, "task");
assert.equal(requests[0].body.target_status, "in_progress");
assert.equal(requests[0].body.expected_current_status, "todo");
assert.equal(requests[0].body.apply, true);
assert.equal(entity.status, "in_progress");
assert.equal(message.textContent, "synced");
assert.equal(saved.result.write_through_result, "applied");
});
test("status control keeps local state on reconciliation conflicts", async () => {
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push({url, options, body: JSON.parse(options.body)});
return {
ok: true,
json: async () => okResponse({
current_status: "done",
target_status: "in_progress",
reconciliation_class: "deferred",
reason: "cached task status changed from expected 'todo' to 'done'",
follow_up: "refresh the dashboard and retry the state change if it is still intended",
write_through_result: "not_applicable",
reconciliation_record_id: "00000000-0000-0000-0000-000000000002",
conflict: true,
}),
};
};
const entity = {id: "00000000-0000-0000-0000-000000000001", status: "todo"};
let saved = null;
const root = statusControl({
entity,
type: "task",
statuses: ["todo", "in_progress"],
onSaved: () => {
saved = true;
},
});
const [select, message] = root.children;
select.value = "in_progress";
await select.listeners.change();
assert.equal(requests.length, 1);
assert.equal(entity.status, "todo");
assert.equal(select.value, "todo");
assert.equal(message.textContent, "out of sync");
assert.equal(saved, null);
});