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) ); const pending = new Map(); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); function messageAttachments(message) { const values = Array.isArray(message?.attachments) ? message.attachments : (Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : [])); return values.filter(Boolean).slice(0, 5); } function attachmentMetadata(value) { const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240); return { filename: String(value.filename || ''), contentType: String(value.contentType || ''), stored: true, ...(note ? { note } : {}), ...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}), ...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}), }; } function durableAttachment(value) { const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240); return { filename: String(value.filename || ''), contentType: String(value.contentType || ''), ...(note ? { note } : {}), ...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}), ...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}), ...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }), }; } function checklistOperation(value) { const action = String(value?.action || ''); if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null; return { action, index:Number(value.index), ...(action === 'rename' ? { label:String(value.label || '') } : {}), }; } function reviewFingerprint(message) { return JSON.stringify({ body: String(message.body || ''), decision: String(message.decision || 'comment'), expectedHeadSha: String(message.expectedHeadSha || ''), comments: Array.isArray(message.comments) ? message.comments : [], }); } function clearConfirmedReviewState(item) { if (item.kind !== 'pull-review') return; try { if (item.draftKey && item.draftFingerprint && storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey); if (item.progressKey && item.progressFingerprint && storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey); } catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ } } function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; return record.items.filter(item => item && supportedKinds.has(item.kind)); } catch (_error) { return []; } } function write(items, mirror = true) { storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); coordinator?.notify('authored'); if (mirror && backgroundSync?.reconcile) { Promise.resolve(backgroundSync.reconcile(items, 'authored')) .then(() => items.length ? backgroundSync.requestSync?.() : undefined) .catch(() => { /* Foreground reconnect remains the compatibility fallback. */ }); } } function enqueue(message, mirror = true) { if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.'); if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) { throw new Error('Choose an exact Search result before queueing a reply.'); } const ownerLogin = String(getOwnerLogin() || '').trim(); if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.'); const items = read(); const requestedOperationId = String(message.operationId || '').slice(0, 128); const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId); if (existing) return { ...existing }; if (message.kind === 'pull-review') { const queuedReview = items.find(item => item.kind === 'pull-review' && item.repository === String(message.repository || '') && item.number === Number(message.number || 0) && item.expectedHeadSha === String(message.expectedHeadSha || '')); if (queuedReview) { if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview }; throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.'); } } if (message.kind === 'issue-content') { const queuedContent = items.find(item => item.kind === 'issue-content' && item.status === 'queued' && item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') && item.number === Number(message.number || 0)); if (queuedContent) { const operation = checklistOperation(message.checklistOperation); const replacement = { ...queuedContent, operationId: String(requestedOperationId || makeId()).slice(0, 128), title: String(message.title || ''), body: String(message.body || ''), ...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}), }; write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror); return replacement; } } if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.'); const id = String(requestedOperationId || makeId()).slice(0, 128); const attachments = messageAttachments(message); const item = { id, operationId: requestedOperationId || id, kind: message.kind, repository: String(message.repository || ''), number: Number(message.number || 0), notificationId: Number(message.notificationId || 0), body: String(message.body || ''), ownerLogin, status: 'queued', queuedAt: Number(now()), ...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}), ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), ...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ? (attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } : { attachments:attachments.map(attachmentMetadata) }) : {}), ...(message.kind === 'pull-review' ? { decision: String(message.decision || 'comment'), expectedHeadSha: String(message.expectedHeadSha || ''), comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [], draftKey: String(message.draftKey || ''), progressKey: String(message.progressKey || ''), draftFingerprint: String(message.draftFingerprint || ''), progressFingerprint: String(message.progressFingerprint || ''), } : {}), ...(message.kind === 'issue-blocker' ? { blockerRepository: String(message.blockerRepository || ''), blockerNumber: Number(message.blockerNumber || 0), present: message.present === true, } : {}), ...(message.kind === 'issue-content' ? { title: String(message.title || ''), baseBody: String(message.baseBody ?? message.body ?? ''), expectedUpdatedAt: String(message.expectedUpdatedAt || ''), ...(checklistOperation(message.checklistOperation) ? { checklistOperations:[checklistOperation(message.checklistOperation)], } : {}), } : {}), }; items.push(item); write(items, mirror); return item; } async function enqueueDurably(message) { const previousItems = read(); const item = enqueue(message, false); const attachments = messageAttachments(message); if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && (!backgroundSync?.reconcile || !backgroundSync?.requestSync)) { write(read().filter(candidate => candidate.id !== item.id), false); throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.'); } if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { return { item, background: false, durability: 'foreground-only' }; } const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? { ...candidate, ...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } : { attachments:attachments.map(durableAttachment) }), } : candidate); try { await backgroundSync.reconcile(durableItems, 'authored'); } catch (error) { write(previousItems, false); throw error; } try { await backgroundSync.requestSync(); return { item, background: true, durability: 'background' }; } catch (error) { return { item, background: false, durability: 'foreground-only', error }; } } function update(id, changes) { let updated = null; write(read().map(item => { if (item.id !== id) return item; const body = String(changes?.body ?? item.body); updated = { ...item, body, operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128), status: 'queued', }; delete updated.error; delete updated.deliveryState; return updated; })); return updated; } function discard(id) { const items = read(); if (!items.some(item => item.id === id)) return false; write(items.filter(item => item.id !== id)); return true; } function endpoint(item) { if (item.kind === 'update-reply') { return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply'; } const repository = item.repository.split('/').map(encodeURIComponent).join('/'); if (item.kind === 'issue-close') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close'; } if (item.kind === 'issue-blocker') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers'; } if (item.kind === 'issue-content') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content'; } if (item.kind === 'pull-review') { return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; } if (item.kind === 'search-reply') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/preview/comments?kind=' + encodeURIComponent(item.targetKind); } const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments'; } async function sendItem(item, currentLogin) { if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (pending.has(item.id)) return pending.get(item.id); const attemptAt = Number(now()); write(read().map(candidate => candidate.id === item.id ? { ...candidate, status:'sending', lastAttemptAt:attemptAt, } : candidate), false); const request = (async () => { try { let result; if (backgroundSync?.send) { const delivery = await backgroundSync.send(item, currentLogin); if (delivery.attention) { const error = delivery.error || new Error('Message needs attention'); error.status = Number(error.status || 422); throw error; } result = delivery.message; } else { if (item.kind === 'update-reply-read') { if (!item.replyConfirmed) { await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ body: item.body }), }); write(read().map(candidate => candidate.id === item.id ? { ...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt, } : candidate), false); } result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', { method: 'PATCH', headers: { Accept: 'application/json' }, }); } else if (item.kind === 'issue-close') { result = await fetchJson(endpoint(item), { method: 'PATCH', headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId }, }); if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.'); } else if (item.kind === 'issue-blocker') { result = await fetchJson(endpoint(item), { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ repository: item.blockerRepository, number: item.blockerNumber, present: item.present, }), }); const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : []; const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository && Number(candidate?.number) === item.blockerNumber); if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) { throw new Error('Blocker change was not confirmed.'); } } else { const body = item.kind === 'pull-review' ? { body: item.body, decision: item.decision, expected_head_sha: item.expectedHeadSha, comments: item.comments, } : item.kind === 'issue-content' ? { title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt, } : { body: item.body }; result = await fetchJson(endpoint(item), { method: item.kind === 'issue-content' ? 'PATCH' : 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify(body), }); if (item.kind === 'issue-content' && (result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) { throw new Error('Checklist update was not confirmed.'); } } } if (!result) return { blocked: true }; clearConfirmedReviewState(item); discard(item.id); return { result }; } catch (error) { const status = Number(error?.status || 0); const permanent = status >= 400 && status < 500; const attemptError = String(error.message || 'Delivery failed').slice(0, 240); if (permanent) { write(read().map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(error.message || 'Message needs attention').slice(0, 240), lastAttemptAt: attemptAt, lastAttemptError: attemptError, ...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}), } : candidate)); } else { write(read().map(candidate => candidate.id === item.id ? { ...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError, } : candidate)); } return { error, transient: !permanent }; } })(); pending.set(item.id, request); try { return await request; } finally { if (pending.get(item.id) === request) pending.delete(item.id); } } async function flushQueue(currentLogin) { const confirmed = []; let blocked = 0; currentLogin = String(currentLogin || '').trim(); for (const item of read()) { if (item.status === 'attention' || item.kind === 'issue-close') continue; if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; } const outcome = await sendItem(item, currentLogin); if (outcome.result) confirmed.push(outcome.result); if (outcome.transient) break; } return { confirmed, remaining: read(), blocked }; } async function flush(currentLogin) { if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin)); return flushQueue(currentLogin); } async function retryItem(id, currentLogin) { const item = read().find(candidate => candidate.id === id); if (!item) return { confirmed: [], remaining: read() }; currentLogin = String(currentLogin || '').trim(); if (!currentLogin || item.ownerLogin !== currentLogin) { return { confirmed: [], remaining: read(), blocked: 1 }; } let queued = item; if (item.status === 'attention') { queued = { ...item, operationId: String(makeId()).slice(0, 128), status: 'queued', }; delete queued.error; delete queued.deliveryState; write(read().map(candidate => candidate.id === id ? queued : candidate)); } const outcome = await sendItem(queued, currentLogin); return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 }; } async function retry(id, currentLogin) { if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin)); 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 => { const background = statuses.get(item.id); if (background?.status === 'sent') { clearConfirmedReviewState(item); return []; } if (background?.status === 'attention') return [{ ...item, status: 'attention', error: String(background.error || 'Message needs attention').slice(0, 240), ...(background.deliveryState ? { deliveryState: background.deliveryState } : {}), }]; if (background?.status === 'authorization') return [{ ...item, status: 'authorization', error: String(background.error || 'Fresh authorization required').slice(0, 240), }]; return [item]; }); write(items); return items; } return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict, reconcileBackground, list: () => read().map(item => ({ ...item })) }; } if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;