163 lines
6.0 KiB
JavaScript
163 lines
6.0 KiB
JavaScript
function createBatchFindWork({
|
|
capacity,
|
|
claim,
|
|
queue,
|
|
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
|
persistEstimate = () => {},
|
|
onProgress = () => {},
|
|
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
|
owner = () => '',
|
|
journalName = 'find-work-batch',
|
|
queueFailureReason = 'assigned but Today sync is unavailable',
|
|
autoMount = true,
|
|
}) {
|
|
let request = null;
|
|
const journalKey = () => 'stackchain.' + journalName + '.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, journal.context);
|
|
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: queueFailureReason, 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 = {}, context = null) {
|
|
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, context,
|
|
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 pending() {
|
|
const journal = readJournal();
|
|
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
|
|
}
|
|
|
|
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 (autoMount && typeof document !== 'undefined') mountRecovery(
|
|
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
|
);
|
|
return { run, resume, pending, mountRecovery };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|