Resolve offline checklist conflicts from mobile Drafts #914

Merged
timmy merged 1 commits from timmy/913-resolve-checklist-conflicts into main 2026-08-15 20:04:13 +00:00
10 changed files with 281 additions and 6 deletions

View File

@ -1,4 +1,4 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@ -113,6 +113,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
} : {}),
...(message.kind === 'issue-content' ? {
title: String(message.title || ''),
baseBody: String(message.baseBody ?? message.body ?? ''),
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
} : {}),
};
@ -363,6 +364,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return retryItem(id, currentLogin);
}
async function resolveIssueContentConflict(id, latest, currentLogin) {
const previousItems = read();
const item = previousItems.find(candidate => candidate.id === id);
currentLogin = String(currentLogin || '').trim();
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
throw new Error('This checklist conflict is no longer available.');
}
if (!currentLogin || item.ownerLogin !== currentLogin) {
throw new Error('Confirm the account that queued this checklist update.');
}
if (typeof mergeChecklistConflict !== 'function') {
throw new Error('Checklist conflict review is unavailable.');
}
const merged = mergeChecklistConflict({
baseBody: String(item.baseBody ?? item.body ?? ''),
localBody: String(item.body || ''),
remoteBody: String(latest?.body || ''),
});
if (merged.conflicts?.length) return merged;
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
const rebased = {
...item,
operationId: String(makeId()).slice(0, 128),
title: String(latest?.title || ''),
baseBody: String(latest?.body || ''),
body: merged.body,
expectedUpdatedAt: String(latest.updated_at),
status: 'queued',
};
delete rebased.error;
delete rebased.deliveryState;
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
write(nextItems, false);
if (backgroundSync?.reconcile) {
try { await backgroundSync.reconcile(nextItems, 'authored'); }
catch (error) {
write(previousItems, false);
throw error;
}
try { await backgroundSync.requestSync?.(); }
catch (_error) { /* Foreground retry remains available. */ }
}
return { ...merged, item: rebased };
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
@ -388,7 +434,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return items;
}
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;

View File

@ -0,0 +1,58 @@
function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
function tasks(body) {
const entries = [];
String(body || '').split('\n').forEach((line, lineIndex) => {
const match = line.match(taskPattern);
if (!match) return;
const label = match[4].trim();
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
if (!key) return;
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
});
return entries;
}
function grouped(entries) {
const result = new Map();
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
return result;
}
const base = grouped(tasks(baseBody));
const local = grouped(tasks(localBody));
const remoteEntries = tasks(remoteBody);
const remote = grouped(remoteEntries);
const changes = [];
const conflicts = [];
for (const [key, baseMatches] of base) {
const localMatches = local.get(key) || [];
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
if (baseMatches[0].checked === localMatches[0].checked) continue;
const remoteMatches = remote.get(key) || [];
if (remoteMatches.length !== 1) {
conflicts.push({
label: baseMatches[0].label,
reason: remoteMatches.length ? 'ambiguous' : 'missing',
});
continue;
}
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
}
if (conflicts.length) return { body: null, changes, conflicts };
const desired = new Map(changes.map(change => [
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
]));
const lines = String(remoteBody || '').split('\n');
remoteEntries.forEach(entry => {
if (!desired.has(entry.key)) return;
const marker = desired.get(entry.key) ? 'x' : ' ';
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
});
return { body: lines.join('\n'), changes, conflicts: [] };
}
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;

View File

@ -673,6 +673,7 @@
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
mergeChecklistConflict: mergeChecklistConflict,
});
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
@ -2745,6 +2746,7 @@
const isUnfiled = item.kind === 'unfiled-issue';
const reviewOutbox = item.outbox_kind === 'pull-review';
const closureOutbox = item.outbox_kind === 'issue-close';
const checklistConflict = item.checklist_conflict === true;
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
@ -2764,6 +2766,9 @@
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
item.kind === 'authored-outbox' && checklistConflict ?
'<button class="draft-review-checklist" data-draft-index="' + index + '" type="button">Review changes</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
item.kind === 'authored-outbox' && closureOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open issue</button>' +
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize &amp; close</button>' +
@ -2873,6 +2878,42 @@
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
});
});
list.querySelectorAll('.draft-review-checklist').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Loading the latest checklist for review…';
try {
const latest = await issueController.load({
repository:item.repository, number:item.number,
});
const preview = mergeChecklistConflict({
baseBody:queued.baseBody, localBody:queued.body, remoteBody:latest.body,
});
if (preview.conflicts.length) {
qs('#my-work-action-status').textContent = 'Checklist changes for ' +
preview.conflicts.map(conflict => conflict.label).join(', ') +
' could not be matched safely. Open the issue to compare renamed or deleted tasks.';
return;
}
if (!window.confirm('Apply ' + preview.changes.length + ' checklist change' +
(preview.changes.length === 1 ? '' : 's') + ' to the latest issue? Remote prose and other tasks will stay unchanged.')) return;
const result = await authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin);
if (result.conflicts.length) return;
const delivery = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(delivery);
qs('#my-work-action-status').textContent = delivery.confirmed?.length ?
'Checklist changes applied to the latest issue.' :
'The issue changed again. Review the checklist conflict against the new version.';
} catch (error) {
qs('#my-work-action-status').textContent = String(error?.message || 'Checklist review could not be loaded. Retry when online.');
} finally {
button.disabled = false;
}
});
});
list.querySelectorAll('.draft-authorize').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];

View File

@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
item.status === 'attention' && typeof item.baseBody === 'string';
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: copyText,
checklist_conflict: checklistConflict,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +

View File

@ -158,7 +158,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
}
const body = toggleTask(detail.body, taskIndex, checked);
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, body, expectedUpdatedAt:detail.updated_at });
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
pendingTask(item, detail, items, ownerLogin) {

View File

@ -36,7 +36,7 @@ FEATURE_SOURCES = {
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
),
}
CACHE_DECLARATION = re.compile(

View File

@ -176,6 +176,43 @@ const outbox = createAuthoredOutbox({{
assert output["items"][0]["body"] == "- [x] Build\n- [ ] Test"
def test_issue_content_conflict_resolution_rebases_task_delta_and_mirrors_before_retry():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const mergeChecklistConflict = require({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'checklist-conflict.js'))});
const initial = {{version:2,items:[{{
id:'check-1',operationId:'old-op',kind:'issue-content',ownerLogin:'timmy',status:'attention',
repository:'o/r',number:9,title:'Ship',baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
body:'Intro\\n- [x] Build\\n- [ ] Test',expectedUpdatedAt:'old-revision',error:'Issue changed',
}}]}};
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(initial)]]);
const mirrors = []; let syncs = 0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'rebased-op', mergeChecklistConflict,
backgroundSync:{{reconcile:async items=>mirrors.push(items.map(item=>({{...item}}))),requestSync:async()=>{{syncs += 1;}}}},
}});
(async()=>{{
const result = await outbox.resolveIssueContentConflict('check-1', {{
title:'Ship safely', body:'Remote intro\\n- [ ] Test\\n- [ ] Build', updated_at:'latest-revision',
}}, 'timmy');
process.stdout.write(JSON.stringify({{result,item:outbox.list()[0],mirrors,syncs}}));
}})();
"""
output = run_node(script)
assert output["result"]["changes"] == [{"label": "Build", "checked": True}]
assert output["item"]["status"] == "queued"
assert output["item"]["operationId"] == "rebased-op"
assert output["item"]["title"] == "Ship safely"
assert output["item"]["body"] == "Remote intro\n- [ ] Test\n- [x] Build"
assert output["item"]["baseBody"] == "Remote intro\n- [ ] Test\n- [ ] Build"
assert output["item"]["expectedUpdatedAt"] == "latest-revision"
assert "error" not in output["item"]
assert output["mirrors"][-1][0] == output["item"]
assert output["syncs"] == 1
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -0,0 +1,49 @@
import json
import subprocess
from pathlib import Path
MERGER = Path(__file__).parents[1] / "frontend" / "checklist-conflict.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_merge_applies_local_task_delta_to_latest_body_without_reverting_remote_edits():
script = f"""
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
const result = mergeChecklistConflict({{
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
localBody:'Intro\\n- [x] Build\\n- [ ] Test',
remoteBody:'Updated intro\\n- [ ] Test\\n- [ ] Build\\n- [x] Document',
}});
process.stdout.write(JSON.stringify(result));
"""
output = run_node(script)
assert output == {
"body": "Updated intro\n- [ ] Test\n- [x] Build\n- [x] Document",
"changes": [{"label": "Build", "checked": True}],
"conflicts": [],
}
def test_merge_reports_missing_or_ambiguous_changed_tasks_without_writing_a_body():
script = f"""
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
const missing = mergeChecklistConflict({{
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Release',
}});
const duplicate = mergeChecklistConflict({{
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Ship\\n- [ ] Ship',
}});
process.stdout.write(JSON.stringify({{missing,duplicate}}));
"""
output = run_node(script)
assert output["missing"]["body"] is None
assert output["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
assert output["duplicate"]["body"] is None
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}]

View File

@ -121,6 +121,28 @@ process.stdout.write(JSON.stringify(drafts));
assert output[1]["label"] == "Queued issue"
def test_draft_inbox_identifies_account_bound_checklist_conflicts_for_review():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const record = {{version:2,items:[{{
id:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'attention',repository:'o/r',number:7,
title:'Ship',baseBody:'- [ ] Build',body:'- [x] Build',expectedUpdatedAt:'old',error:'Issue changed',queuedAt:12,
}}]}};
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(record)]]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const owned = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
const other = createDraftInbox({{storage,getCurrentLogin:()=>'alexander'}}).list()[0];
process.stdout.write(JSON.stringify({{owned,other}}));
"""
output = run_node(script)
assert output["owned"]["label"] == "Checklist conflict"
assert output["owned"]["checklist_conflict"] is True
assert output["owned"]["quarantined"] is False
assert output["other"]["checklist_conflict"] is False
assert output["other"]["quarantined"] is True
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});

View File

@ -33,6 +33,7 @@ LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
FRONTEND_BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
@ -2224,6 +2225,22 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
@pytest.mark.anyio
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
html = await dashboard()
assert "mergeChecklistConflict: mergeChecklistConflict" in html
assert "const checklistConflict = item.checklist_conflict === true" in html
assert 'class="draft-review-checklist"' in html
assert ">Review changes</button>" in html
assert "const latest = await issueController.load({" in html
assert "authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin)" in html
assert "window.confirm('Apply ' + preview.changes.length + ' checklist change'" in html
assert "await authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "could not be matched safely" in html
assert "checklist-conflict.js" in FRONTEND_BUNDLE.read_text()
def test_completed_issue_checklist_offers_close_and_next_during_today():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@ -2326,7 +2343,8 @@ pending.then(result => process.stdout.write(JSON.stringify({{before,queued,resul
assert output["before"] is False
assert output["queued"] == [{
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
"title": "Release", "body": "- [x] Build\n- [X] Ship",
"title": "Release", "baseBody": "- [ ] Build\n- [X] Ship",
"body": "- [x] Build\n- [X] Ship",
"expectedUpdatedAt": "2026-08-15T10:00:00Z",
}]
assert output["result"] == {