diff --git a/frontend/batch-find-work.js b/frontend/batch-find-work.js index 5b3eeb2..4e5ed3a 100644 --- a/frontend/batch-find-work.js +++ b/frontend/batch-find-work.js @@ -8,6 +8,7 @@ function createBatchFindWork({ storage = typeof localStorage === 'undefined' ? null : localStorage, owner = () => '', journalName = 'find-work-batch', + queueFailureReason = 'assigned but Today sync is unavailable', autoMount = true, }) { let request = null; @@ -58,7 +59,7 @@ function createBatchFindWork({ entry.state = 'assigned'; writeJournal(journal); } - const queueResult = await queue(confirmed); + const queueResult = await queue(confirmed, journal.context); if (queueResult === 'queued' || queueResult === 'exists') { queued.push(key(item)); entry.state = 'queued'; @@ -67,7 +68,7 @@ function createBatchFindWork({ } writeJournal(journal); } else { - failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true }); + failed.push({ key: key(item), reason: queueFailureReason, assigned: true }); } } catch (error) { failed.push({ key: key(item), reason: error?.message || 'assignment failed' }); @@ -81,7 +82,7 @@ function createBatchFindWork({ return outcome; } - function run(items, estimates = {}) { + function run(items, estimates = {}, context = null) { if (request) return request; const selected = Array.isArray(items) ? items.slice() : []; const available = Math.max(0, Number(capacity()) || 0); @@ -112,7 +113,7 @@ function createBatchFindWork({ } } const journal = { - owner: String(owner() || ''), available, + owner: String(owner() || ''), available, context, items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })), }; writeJournal(journal); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5aea306..896375c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -129,7 +129,7 @@ textarea { resize: vertical; min-height: 120px; } .cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; } .cmd-select-result input { width:22px; height:22px; } .cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; } -.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; } +.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; } .search-batch-actions[hidden] { display:none; } .search-batch-actions span { grid-column:1 / -1; } .search-batch-actions button, .search-batch-recovery { min-height:44px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4199868..989ba6c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4424,8 +4424,8 @@ }); searchBatchPlanning = mountSearchBatchPlanning( document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson, - searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, index=>commandItems[index]?.result, - ()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr + searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result, + ()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker ); searchDefer = createSearchDefer({ claim: detail => searchPreview.claim(detail), diff --git a/frontend/index.html b/frontend/index.html index ac1ee35..eb59c26 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -458,6 +458,7 @@
document.querySelector(selector); let processor; + let laterProcessor; const plan = createSearchBatchPlan({ resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}), claim:detail => fetchJson( @@ -77,6 +79,7 @@ get('#search-batch-actions').hidden = !state.active; get('#select-search-results').hidden = state.active; get('#queue-selected-search-results').disabled = state.count === 0; + get('#defer-selected-search-results').disabled = state.count === 0; get('#search-selection-status').textContent = state.count ? state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.'; render(); @@ -125,6 +128,30 @@ } }, }); + laterProcessor = batchFactory({ + capacity:() => plan.limit, + owner:getOwner, + journalName:'search-later-batch', + queueFailureReason:'assigned but Later could not be saved', + autoMount:false, + claim:item => plan.prepare(item), + queue:(confirmed, context) => { + const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until); + return outcome === 'deferred' ? 'queued' : outcome; + }, + onProgress:progress => { + get('#defer-selected-search-results').disabled = progress.status === 'running'; + if (progress.status === 'running') { + get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…'; + } else if (progress.status === 'complete') { + get('#cmd-search-action-status').textContent = progress.failed.length ? + progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' : + progress.queued.length + ' added to Later.'; + restore(); + if (!progress.failed.length) plan.cancel(); + } + }, + }); function estimateValues() { return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input => [input.dataset.searchBatchEstimate, Number(input.value)] @@ -161,11 +188,21 @@ if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items); else processor.run(items); }); + get('#defer-selected-search-results').addEventListener('click', event => { + const items = plan.snapshot().items; + if (!items.length) return; + laterPicker?.open({ items, confirm:until => () => laterProcessor.run( + items, {}, { until:new Date(until).toISOString() } + ) }, event.currentTarget, 'search-batch'); + }); get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview); get('#confirm-search-batch-estimates').addEventListener('click', () => processor.run(plan.snapshot().items, estimateValues()) ); - get('#resume-search-batch').addEventListener('click', () => processor.resume()); + get('#resume-search-batch').addEventListener('click', () => { + if (processor.pending()) return processor.resume(); + return laterProcessor.resume(); + }); get('#open-palette').addEventListener('click', () => restore()); get('#cmd-results').addEventListener('change', event => { const index = event.target?.dataset?.searchSelect; @@ -174,7 +211,7 @@ if (result) plan.toggle(result); }); function restore() { - const pending = processor.pending(); + const pending = processor.pending() + laterProcessor.pending(); get('#resume-search-batch').hidden = pending === 0; get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted'; } @@ -188,7 +225,7 @@ escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + (allowed ? 'Open issue' : 'Not eligible') + ''; } - return { plan, processor, restore, resultHtml }; + return { plan, processor, laterProcessor, restore, resultHtml }; }; return createSearchBatchPlan; diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py index 88c0fa0..222a9fb 100644 --- a/tests/test_batch_find_work.py +++ b/tests/test_batch_find_work.py @@ -155,6 +155,55 @@ first.run([item]).then(firstResult=>{{ } +def test_interrupted_batch_restores_operation_context_for_idempotent_recovery(): + 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 options={{ + capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch', + queueFailureReason:'assigned but Later could not be saved', + claim:item=>Promise.resolve({{...item,assigned_to_me:true}}), +}}; +const first=createBatchFindWork({{...options, queue:(item,context)=>{{ + calls.push(['first',item.number,context?.until]); + return item.number===813 ? 'queued' : 'unavailable'; +}}}}); +const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}})); +first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{ + const second=createBatchFindWork({{...options, queue:(item,context)=>{{ + calls.push(['resume',item.number,context?.until]); + return 'queued'; + }}}}); + return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{ + firstResult,resumed,calls,keys:Object.keys(values), + }}))); +}}); +""" + + result = run_node(script) + assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"] + assert result["firstResult"]["failed"] == [{ + "key": "stackchain/dashboard#814", + "reason": "assigned but Later could not be saved", + "assigned": True, + }] + assert result["resumed"]["queued"] == [ + "stackchain/dashboard#813", "stackchain/dashboard#814", + ] + assert result["calls"] == [ + ["first", 813, "2026-08-15T09:00:00.000Z"], + ["first", 814, "2026-08-15T09:00:00.000Z"], + ["resume", 814, "2026-08-15T09:00:00.000Z"], + ] + assert result["keys"] == [] + + def test_resumed_batch_skips_queued_items_and_continues_in_original_order(): script = f""" const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))}); diff --git a/tests/test_later_picker.py b/tests/test_later_picker.py index 556edad..9c6656a 100644 --- a/tests/test_later_picker.py +++ b/tests/test_later_picker.py @@ -132,3 +132,22 @@ process.stdout.write(JSON.stringify({{first, second, confirmations}})); output = run_node(script) assert output == {"first": True, "second": False, "confirmations": 1} + + +def test_picker_item_can_own_batch_confirmation_and_run_after_close(): + script = f""" +const createLaterPicker = require({json.dumps(str(LATER_PICKER))}); +const order=[]; +const picker=createLaterPicker({{ + now:() => new Date(2026,7,14,8,0), + onConfirm:()=>{{order.push('global');return true;}}, +}}); +picker.open({{confirm:until=>() => order.push(until.toISOString())}},null,'search-batch'); +const saved=picker.submit('2026-08-15T09:00'); +process.stdout.write(JSON.stringify({{saved,order}})); +""" + + assert run_node(script) == { + "saved": True, + "order": ["2026-08-15T09:00:00.000Z"], + } diff --git a/tests/test_search_batch_plan.py b/tests/test_search_batch_plan.py index 886a0b3..2e9e34f 100644 --- a/tests/test_search_batch_plan.py +++ b/tests/test_search_batch_plan.py @@ -79,10 +79,13 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls(): assert 'id="select-search-results"' in html assert 'id="search-batch-actions"' in html assert 'id="queue-selected-search-results"' in html + assert 'id="defer-selected-search-results"' in html assert 'id="resume-search-batch"' in html assert 'src="static/search-batch-plan.js"' in html assert "mountSearchBatchPlanning(" in dashboard + assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard assert "journalName:'search-today-batch'" in controller + assert "journalName:'search-later-batch'" in controller assert "autoMount:false" in controller assert "plan.toggle(result)" in controller assert "processor.run(plan.snapshot().items, estimateValues())" in controller @@ -100,6 +103,73 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls(): assert "BASE + 'static/search-batch-plan.js'" in worker +def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer(): + script = f""" +const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); +const listeners={{}}; +const elements={{}}; +for (const id of [ + 'search-batch-actions','select-search-results','queue-selected-search-results', + 'defer-selected-search-results','search-selection-status','cmd-search-action-status', + 'resume-search-batch','open-palette','cmd-results','cancel-search-selection', + 'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list', + 'cancel-search-batch-estimates','confirm-search-batch-estimates' +]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'', + addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}}; +const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}}; +const processorOptions=[]; +const runs=[]; +const processors=[]; +const batchFactory=options=>{{ + processorOptions.push(options); + const processor={{ + run:(items,estimates,context)=>{{runs.push({{items,estimates,context}});return Promise.resolve();}}, + resume:()=>Promise.resolve(),pending:()=>0, + }}; + processors.push(processor); + return processor; +}}; +const planning={{capacity_minutes:null,estimates:{{}}}}; +const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number, + replacePlanning:()=>{{}}}}; +const laterCalls=[]; +const laterWork={{defer:(item,until)=>{{laterCalls.push([item.number,until]);return 'deferred';}}}}; +let pickerRequest=null; +const laterPicker={{open:(payload,trigger,context)=>{{pickerRequest={{payload,trigger,context}};return true;}}}}; +const item={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:813,title:'Batch defer'}}; +const mounted=createSearchBatchPlan.mount( + document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'), + {{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},value=>({{...value,accepted:true}}),()=>item,()=>{{}}, + value=>value,value=>value,laterWork,laterPicker +); +mounted.plan.start(); +mounted.plan.toggle(item); +listeners['defer-selected-search-results:click']({{currentTarget:elements['#defer-selected-search-results']}}); +const before={{runs:runs.slice(),count:pickerRequest.payload.items.length,context:pickerRequest.context,journals:processorOptions.map(value=>value.journalName)}}; +pickerRequest.payload.confirm(new Date('2026-08-15T09:00:00.000Z'))(); +const laterResult=processorOptions[1].queue({{...item,assigned_to_me:true}},{{until:'2026-08-15T09:00:00.000Z'}}); +process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}})); +""" + + result = run_node(script) + assert result["before"] == { + "runs": [], + "count": 1, + "context": "search-batch", + "journals": ["search-today-batch", "search-later-batch"], + } + assert result["runs"] == [{ + "items": [{ + "kind": "issue", "state": "open", "repository": "stackchain/dashboard", + "number": 813, "title": "Batch defer", + }], + "estimates": {}, + "context": {"until": "2026-08-15T09:00:00.000Z"}, + }] + assert result["laterResult"] == "queued" + assert result["laterCalls"] == [[813, "2026-08-15T09:00:00.000Z"]] + + def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); @@ -107,6 +177,7 @@ const listeners={{}}; const elements={{}}; for (const id of [ 'search-batch-actions','select-search-results','queue-selected-search-results', + 'defer-selected-search-results', 'search-selection-status','cmd-search-action-status','resume-search-batch', 'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review', 'search-batch-estimate-summary','search-batch-estimate-list', @@ -122,12 +193,12 @@ const document={{ querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [], }}; const calls=[]; -let processorOptions; +const processorOptions=[]; const processor={{ run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}}, resume:()=>Promise.resolve(),pending:()=>0, }}; -const batchFactory=options=>{{processorOptions=options;return processor;}}; +const batchFactory=options=>{{processorOptions.push(options);return processor;}}; const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}}; const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number, replacePlanning:value=>Object.assign(planning,value)}}; @@ -149,11 +220,11 @@ const before={{ reviewHidden:elements['#search-batch-estimate-review'].hidden, actionsHidden:elements['#search-batch-actions'].hidden, markup:elements['#search-batch-estimate-list'].innerHTML, - budget:processorOptions.timeBudget(), + budget:processorOptions[0].timeBudget(), }}; listeners['confirm-search-batch-estimates:click'](); -processorOptions.persistEstimate(items[0],25); -processorOptions.onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}}); +processorOptions[0].persistEstimate(items[0],25); +processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}}); const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden; process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}})); """