generated from coulomb/repo-seed
Harden reconciliation conflict handling
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "observable preview",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"build": "observable build",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
|
||||
@@ -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});
|
||||
|
||||
165
dashboard/test/status-control.test.mjs
Normal file
165
dashboard/test/status-control.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user