function createBatchFindWork({ capacity, claim, queue, timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }), persistEstimate = () => {}, onProgress = () => {}, storage = typeof localStorage === 'undefined' ? null : localStorage, owner = () => '', }) { 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 = []) { return { status, selected, available, queued, failed }; } function key(item) { 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 = {}) { if (request) return request; const selected = Array.isArray(items) ? items.slice() : []; const available = Math.max(0, Number(capacity()) || 0); if (selected.length > available) { const outcome = result('full', selected.length, available); onProgress(outcome); return Promise.resolve(outcome); } const budget = timeBudget() || {}; if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) { const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0)); const invalid = selected.map(key).filter(id => !Number.isInteger(estimates[id]) || estimates[id] <= 0 ); const requested = selected.reduce((sum, item) => sum + (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0); if (invalid.length) { const outcome = { ...result('estimates-required', selected.length, available), remaining_minutes: remaining, requested_minutes: requested, invalid }; onProgress(outcome); return Promise.resolve(outcome); } if (requested > remaining) { const outcome = { ...result('over-budget', selected.length, available), remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining }; onProgress(outcome); return Promise.resolve(outcome); } } const journal = { owner: String(owner() || ''), available, items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })), }; writeJournal(journal); request = processJournal(journal).finally(() => { request = null; }); return request; } 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;