Resume interrupted Find Work batches without duplicate claims #680
|
|
@ -5,8 +5,28 @@ function createBatchFindWork({
|
||||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||||
persistEstimate = () => {},
|
persistEstimate = () => {},
|
||||||
onProgress = () => {},
|
onProgress = () => {},
|
||||||
|
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||||
|
owner = () => '',
|
||||||
}) {
|
}) {
|
||||||
let request = null;
|
let request = null;
|
||||||
|
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||||
|
|
||||||
|
function readJournal() {
|
||||||
|
if (!storage || !String(owner() || '')) return null;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||||
|
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJournal(value) {
|
||||||
|
if (!storage || !String(owner() || '')) return;
|
||||||
|
storage.setItem(journalKey(), JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearJournal() {
|
||||||
|
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||||
|
}
|
||||||
|
|
||||||
function result(status, selected, available, queued = [], failed = []) {
|
function result(status, selected, available, queued = [], failed = []) {
|
||||||
return { status, selected, available, queued, failed };
|
return { status, selected, available, queued, failed };
|
||||||
|
|
@ -16,6 +36,48 @@ function createBatchFindWork({
|
||||||
return String(item.repository || '') + '#' + String(item.number || '');
|
return String(item.repository || '') + '#' + String(item.number || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processJournal(journal) {
|
||||||
|
const selected = journal.items;
|
||||||
|
const queued = [];
|
||||||
|
const failed = [];
|
||||||
|
for (let index = 0; index < selected.length; index += 1) {
|
||||||
|
const entry = selected[index];
|
||||||
|
const item = entry.item;
|
||||||
|
if (entry.state === 'queued') {
|
||||||
|
queued.push(key(item));
|
||||||
|
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const confirmed = entry.confirmed || await claim(item);
|
||||||
|
if (!entry.confirmed) {
|
||||||
|
entry.confirmed = confirmed;
|
||||||
|
entry.state = 'assigned';
|
||||||
|
writeJournal(journal);
|
||||||
|
}
|
||||||
|
const queueResult = await queue(confirmed);
|
||||||
|
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||||
|
queued.push(key(item));
|
||||||
|
entry.state = 'queued';
|
||||||
|
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||||
|
persistEstimate(confirmed, entry.estimate);
|
||||||
|
}
|
||||||
|
writeJournal(journal);
|
||||||
|
} else {
|
||||||
|
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||||
|
}
|
||||||
|
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||||
|
}
|
||||||
|
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||||
|
if (!failed.length) clearJournal();
|
||||||
|
else writeJournal(journal);
|
||||||
|
onProgress({ ...outcome, processed: selected.length });
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
function run(items, estimates = {}) {
|
function run(items, estimates = {}) {
|
||||||
if (request) return request;
|
if (request) return request;
|
||||||
const selected = Array.isArray(items) ? items.slice() : [];
|
const selected = Array.isArray(items) ? items.slice() : [];
|
||||||
|
|
@ -46,39 +108,46 @@ function createBatchFindWork({
|
||||||
return Promise.resolve(outcome);
|
return Promise.resolve(outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
request = (async () => {
|
const journal = {
|
||||||
const queued = [];
|
owner: String(owner() || ''), available,
|
||||||
const failed = [];
|
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||||
for (let index = 0; index < selected.length; index += 1) {
|
};
|
||||||
const item = selected[index];
|
writeJournal(journal);
|
||||||
try {
|
request = processJournal(journal).finally(() => { request = null; });
|
||||||
const confirmed = await claim(item);
|
|
||||||
const queueResult = await queue(confirmed);
|
|
||||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
|
||||||
queued.push(key(item));
|
|
||||||
if (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0) {
|
|
||||||
persistEstimate(confirmed, estimates[key(item)]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
failed.push({
|
|
||||||
key: key(item),
|
|
||||||
reason: 'assigned but Today sync is unavailable',
|
|
||||||
assigned: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
|
||||||
}
|
|
||||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
|
||||||
}
|
|
||||||
const outcome = result('complete', selected.length, available, queued, failed);
|
|
||||||
onProgress({ ...outcome, processed: selected.length });
|
|
||||||
return outcome;
|
|
||||||
})().finally(() => { request = null; });
|
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { run };
|
function resume() {
|
||||||
|
if (request) return request;
|
||||||
|
const journal = readJournal();
|
||||||
|
if (!journal) return Promise.resolve(null);
|
||||||
|
request = processJournal(journal).finally(() => { request = null; });
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRecovery(button, opener) {
|
||||||
|
const show = () => {
|
||||||
|
const journal = readJournal();
|
||||||
|
button.hidden = !journal;
|
||||||
|
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||||
|
};
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
const outcome = await resume();
|
||||||
|
button.disabled = false;
|
||||||
|
show();
|
||||||
|
if (outcome) {
|
||||||
|
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||||
|
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
opener.addEventListener('click', show);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') mountRecovery(
|
||||||
|
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||||
|
);
|
||||||
|
return { run, resume, mountRecovery };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.find-work-batch-actions[hidden] { display:none; }
|
.find-work-batch-actions[hidden] { display:none; }
|
||||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||||
.find-work-batch-actions button { min-height:44px; }
|
.find-work-batch-actions button { min-height:44px; }
|
||||||
|
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||||
.find-work-estimate-review[hidden] { display:none; }
|
.find-work-estimate-review[hidden] { display:none; }
|
||||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@
|
||||||
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
console.warn('Panel state save failed', e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const mobileTaskButtons = Object.fromEntries(
|
const mobileTaskButtons = Object.fromEntries(
|
||||||
|
|
@ -1393,7 +1391,7 @@
|
||||||
|
|
||||||
const batchFindWork = createBatchFindWork({
|
const batchFindWork = createBatchFindWork({
|
||||||
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
|
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||||
timeBudget: () => {
|
owner:()=>planningOwnerLogin,timeBudget:()=>{
|
||||||
const plan = todayWork.planning();
|
const plan = todayWork.planning();
|
||||||
return {
|
return {
|
||||||
capacity_minutes: plan.capacity_minutes,
|
capacity_minutes: plan.capacity_minutes,
|
||||||
|
|
@ -4310,6 +4308,7 @@
|
||||||
input.focus();
|
input.focus();
|
||||||
});
|
});
|
||||||
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
||||||
|
|
||||||
qs('#claim-selected-work').addEventListener('click', async event => {
|
qs('#claim-selected-work').addEventListener('click', async event => {
|
||||||
if (todayWork.planning().capacity_minutes !== null) {
|
if (todayWork.planning().capacity_minutes !== null) {
|
||||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,8 @@
|
||||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||||
|
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||||
|
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||||
<div class="find-work-list" id="find-work-list"></div>
|
<div class="find-work-list" id="find-work-list"></div>
|
||||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,82 @@ flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||||
|
script = f"""
|
||||||
|
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||||
|
const values={{}};
|
||||||
|
const storage={{
|
||||||
|
getItem:key=>values[key] || null,
|
||||||
|
setItem:(key,value)=>{{values[key]=value;}},
|
||||||
|
removeItem:key=>{{delete values[key];}},
|
||||||
|
}};
|
||||||
|
const calls=[];
|
||||||
|
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||||
|
const first=createBatchFindWork({{
|
||||||
|
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||||
|
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||||
|
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||||
|
}});
|
||||||
|
first.run([item]).then(firstResult=>{{
|
||||||
|
const second=createBatchFindWork({{
|
||||||
|
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||||
|
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||||
|
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||||
|
}});
|
||||||
|
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||||
|
firstResult, resumed, calls, keys:Object.keys(values),
|
||||||
|
}})));
|
||||||
|
}});
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {
|
||||||
|
"firstResult": {
|
||||||
|
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||||
|
"failed": [{
|
||||||
|
"key": "stackchain/dashboard#679",
|
||||||
|
"reason": "assigned but Today sync is unavailable",
|
||||||
|
"assigned": True,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"resumed": {
|
||||||
|
"status": "complete", "selected": 1, "available": 1,
|
||||||
|
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||||
|
},
|
||||||
|
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||||
|
"keys": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||||
|
script = f"""
|
||||||
|
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||||
|
const key='stackchain.find-work-batch.v1.timmy';
|
||||||
|
const values={{[key]:JSON.stringify({{
|
||||||
|
owner:'timmy', available:2, items:[
|
||||||
|
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||||
|
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||||
|
],
|
||||||
|
}})}};
|
||||||
|
const calls=[];
|
||||||
|
const flow=createBatchFindWork({{
|
||||||
|
capacity:()=>2, owner:()=> 'timmy',
|
||||||
|
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||||
|
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||||
|
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||||
|
}});
|
||||||
|
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {
|
||||||
|
"result": {
|
||||||
|
"status": "complete", "selected": 2, "available": 2,
|
||||||
|
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||||
|
"failed": [],
|
||||||
|
},
|
||||||
|
"calls": ["claim:679", "queue:679"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||||
|
|
@ -178,7 +254,7 @@ def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||||
assert 'id="find-work-estimate-list"' in html
|
assert 'id="find-work-estimate-list"' in html
|
||||||
assert 'id="confirm-find-work-estimates"' in html
|
assert 'id="confirm-find-work-estimates"' in html
|
||||||
assert 'inputmode="numeric"' in dashboard
|
assert 'inputmode="numeric"' in dashboard
|
||||||
assert "timeBudget: () =>" in dashboard
|
assert "timeBudget:" in dashboard
|
||||||
assert "persistEstimate:" in dashboard
|
assert "persistEstimate:" in dashboard
|
||||||
assert ".find-work-estimate-review" in css
|
assert ".find-work-estimate-review" in css
|
||||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||||
|
|
@ -292,6 +368,18 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||||
|
html = HTML.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
css = CSS.read_text()
|
||||||
|
|
||||||
|
assert 'id="resume-find-work-batch"' in html
|
||||||
|
assert 'aria-describedby="find-work-status"' in html
|
||||||
|
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||||
|
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||||
|
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||||
html = HTML.read_text()
|
html = HTML.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user