Queue authored conversation messages during connectivity failures #235
12
README.md
12
README.md
|
|
@ -87,10 +87,14 @@ saved time and renders this snapshot read-only; opening live details, pagination
|
||||||
and server mutations remain disabled until reconnection. **Clear offline work
|
and server mutations remain disabled until reconnection. **Clear offline work
|
||||||
data** deletes the snapshot, and opting out deletes it automatically.
|
data** deletes the snapshot, and opting out deletes it automatically.
|
||||||
|
|
||||||
API responses and mutations are never cached by the service worker or queued;
|
API responses and mutations are never cached by the service worker. New issue captures,
|
||||||
submissions still require connectivity. Service-worker upgrades are atomic and
|
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||||
remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
|
outboxes when connectivity or a retryable server failure prevents delivery. Drafts
|
||||||
the same origin.
|
shows queued and needs-attention messages with explicit send/discard controls; reconnect
|
||||||
|
flushes messages sequentially with their original idempotency keys. State-sensitive
|
||||||
|
actions such as reviews, merges, closures, labels, milestones, and assignments are never
|
||||||
|
queued. Service-worker upgrades are atomic and remove only older
|
||||||
|
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
|
||||||
|
|
||||||
Run the test suite with:
|
Run the test suite with:
|
||||||
|
|
||||||
|
|
|
||||||
133
frontend/authored-outbox.js
Normal file
133
frontend/authored-outbox.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
function createAuthoredOutbox({ storage, fetchJson, createOperationId, 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', 'update-reply']);
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||||
|
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||||
|
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||||
|
} catch (_error) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(items) {
|
||||||
|
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(message) {
|
||||||
|
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||||
|
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 (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 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 || ''),
|
||||||
|
status: 'queued',
|
||||||
|
queuedAt: Number(now()),
|
||||||
|
};
|
||||||
|
items.push(item);
|
||||||
|
write(items);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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('/');
|
||||||
|
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||||
|
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendItem(item) {
|
||||||
|
if (pending.has(item.id)) return pending.get(item.id);
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(endpoint(item), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Idempotency-Key': item.operationId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: item.body }),
|
||||||
|
});
|
||||||
|
discard(item.id);
|
||||||
|
return { result };
|
||||||
|
} catch (error) {
|
||||||
|
const status = Number(error?.status || 0);
|
||||||
|
const permanent = status >= 400 && status < 500;
|
||||||
|
if (permanent) {
|
||||||
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
|
...candidate,
|
||||||
|
status: 'attention',
|
||||||
|
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||||
|
} : 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 flush() {
|
||||||
|
const confirmed = [];
|
||||||
|
for (const item of read()) {
|
||||||
|
if (item.status === 'attention') continue;
|
||||||
|
const outcome = await sendItem(item);
|
||||||
|
if (outcome.result) confirmed.push(outcome.result);
|
||||||
|
if (outcome.transient) break;
|
||||||
|
}
|
||||||
|
return { confirmed, remaining: read() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry(id) {
|
||||||
|
const item = read().find(candidate => candidate.id === id);
|
||||||
|
if (!item) return { confirmed: [], remaining: read() };
|
||||||
|
const queued = item.status === 'attention' ? update(id, item) : item;
|
||||||
|
const outcome = await sendItem(queued);
|
||||||
|
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||||
|
|
@ -118,6 +118,32 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||||
} catch (_error) { return []; }
|
} catch (_error) { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAuthoredOutbox(raw) {
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(raw);
|
||||||
|
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||||
|
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||||
|
.map(item => {
|
||||||
|
const isUpdate = item.kind === 'update-reply';
|
||||||
|
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||||
|
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||||
|
return {
|
||||||
|
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||||
|
outbox_id: item.id,
|
||||||
|
kind: 'authored-outbox',
|
||||||
|
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||||
|
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
|
||||||
|
repository: isUpdate ? '' : item.repository,
|
||||||
|
title: target,
|
||||||
|
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||||
|
updated_at: Number(item.queuedAt || 0),
|
||||||
|
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||||
|
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (_error) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
function list() {
|
function list() {
|
||||||
const index = readIndex();
|
const index = readIndex();
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
@ -132,6 +158,11 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'stackchain.authored-outbox.v1') {
|
||||||
|
parseAuthoredOutbox(raw).forEach(item => drafts.push(item));
|
||||||
|
seen.add(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = parse(key, raw);
|
const parsed = parse(key, raw);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/drafts.js"></script>
|
<script src="static/drafts.js"></script>
|
||||||
<script src="static/issue-outbox.js"></script>
|
<script src="static/issue-outbox.js"></script>
|
||||||
|
<script src="static/authored-outbox.js"></script>
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
<script src="static/pick-work.js"></script>
|
<script src="static/pick-work.js"></script>
|
||||||
|
|
@ -830,6 +831,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
});
|
});
|
||||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||||
|
const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||||
const shareParams = new URLSearchParams(location.search);
|
const shareParams = new URLSearchParams(location.search);
|
||||||
const sharedLaunch = {
|
const sharedLaunch = {
|
||||||
title: shareParams.get('title') || '',
|
title: shareParams.get('title') || '',
|
||||||
|
|
@ -985,6 +987,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const notificationReplier = createNotificationReplier({
|
const notificationReplier = createNotificationReplier({
|
||||||
post: postNotificationReply,
|
post: postNotificationReply,
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
|
authoredOutbox,
|
||||||
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
||||||
});
|
});
|
||||||
const notificationReader = createNotificationReader({
|
const notificationReader = createNotificationReader({
|
||||||
|
|
@ -1251,13 +1254,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
function renderDrafts() {
|
function renderDrafts() {
|
||||||
const list = qs('#my-work-list');
|
const list = qs('#my-work-list');
|
||||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||||
|
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||||
const outboxActions = item.kind === 'issue-outbox' ?
|
const outboxActions = item.kind === 'issue-outbox' ?
|
||||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||||
|
item.kind === 'authored-outbox' ?
|
||||||
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||||||
|
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
||||||
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||||
const state = item.kind === 'issue-outbox' ?
|
const state = isOutbox ?
|
||||||
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
|
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
|
||||||
return '<article class="my-work-card draft-card">' +
|
return '<article class="my-work-card draft-card">' +
|
||||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||||
|
|
@ -1290,7 +1298,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (!item?.outbox_id) return;
|
if (!item?.outbox_id) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
|
||||||
|
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||||
|
|
@ -1298,6 +1307,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||||
|
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||||
else if (item) draftInbox.discard(item.id);
|
else if (item) draftInbox.discard(item.id);
|
||||||
lastDrafts = draftInbox.list();
|
lastDrafts = draftInbox.list();
|
||||||
const count = qs('[data-work-count="draft"]');
|
const count = qs('[data-work-count="draft"]');
|
||||||
|
|
@ -1970,6 +1980,25 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
applyOutboxResult(await issueOutbox.flush());
|
applyOutboxResult(await issueOutbox.flush());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAuthoredOutboxResult(result) {
|
||||||
|
refreshMyWorkView();
|
||||||
|
const attention = (result.remaining || []).some(item => item.status === 'attention');
|
||||||
|
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||||||
|
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
|
||||||
|
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
|
||||||
|
'Message queued for sync when the connection returns.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAuthoredOutbox() {
|
||||||
|
if (!navigator.onLine || !authoredOutbox.list().length) return;
|
||||||
|
applyAuthoredOutboxResult(await authoredOutbox.flush());
|
||||||
|
}
|
||||||
|
|
||||||
|
function canQueueMessage(error) {
|
||||||
|
const status = Number(error?.status || 0);
|
||||||
|
return !status || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
function inlineAnchor(target) {
|
function inlineAnchor(target) {
|
||||||
return {
|
return {
|
||||||
path: target.dataset.reviewFilename,
|
path: target.dataset.reviewFilename,
|
||||||
|
|
@ -2802,8 +2831,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#issue-comment').value = '';
|
qs('#issue-comment').value = '';
|
||||||
qs('#issue-comment-status').textContent = 'Comment posted.';
|
qs('#issue-comment-status').textContent = 'Comment posted.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
if (canQueueMessage(error)) {
|
||||||
qs('#issue-comment').focus();
|
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
||||||
|
authoredOutbox.enqueue({ kind:'issue-comment', repository:selectedIssue.repository,
|
||||||
|
number:selectedIssue.number, body, operationId });
|
||||||
|
qs('#issue-comment').value = '';
|
||||||
|
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
|
||||||
|
refreshMyWorkView();
|
||||||
|
} else {
|
||||||
|
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
|
qs('#issue-comment').focus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -2894,8 +2932,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#pull-comment').value = '';
|
qs('#pull-comment').value = '';
|
||||||
qs('#pull-comment-status').textContent = 'Comment posted.';
|
qs('#pull-comment-status').textContent = 'Comment posted.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
if (canQueueMessage(error)) {
|
||||||
qs('#pull-comment').focus();
|
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation');
|
||||||
|
authoredOutbox.enqueue({ kind:'pull-comment', repository:selectedPull.repository,
|
||||||
|
number:selectedPull.number, body, operationId });
|
||||||
|
qs('#pull-comment').value = '';
|
||||||
|
qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
|
||||||
|
refreshMyWorkView();
|
||||||
|
} else {
|
||||||
|
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
|
qs('#pull-comment').focus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -2948,7 +2995,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#send-update-reply').disabled = true;
|
qs('#send-update-reply').disabled = true;
|
||||||
const result = await notificationReplier.submit(selectedUpdate, body);
|
const result = await notificationReplier.submit(selectedUpdate, body);
|
||||||
qs('#send-update-reply').disabled = false;
|
qs('#send-update-reply').disabled = false;
|
||||||
if (result) {
|
if (result?.queued) {
|
||||||
|
qs('#update-reply').value = '';
|
||||||
|
refreshMyWorkView();
|
||||||
|
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
||||||
|
} else if (result) {
|
||||||
notificationReader.appendReply(result);
|
notificationReader.appendReply(result);
|
||||||
qs('#update-reply').value = '';
|
qs('#update-reply').value = '';
|
||||||
qs('#mark-update-read-next').focus();
|
qs('#mark-update-read-next').focus();
|
||||||
|
|
@ -3152,6 +3203,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
window.addEventListener('offline', showOfflineStatus);
|
window.addEventListener('offline', showOfflineStatus);
|
||||||
window.addEventListener('online', reconnectLiveData);
|
window.addEventListener('online', reconnectLiveData);
|
||||||
window.addEventListener('online', flushIssueOutbox);
|
window.addEventListener('online', flushIssueOutbox);
|
||||||
|
window.addEventListener('online', flushAuthoredOutbox);
|
||||||
|
|
||||||
qs('#refresh').addEventListener('click', load);
|
qs('#refresh').addEventListener('click', load);
|
||||||
qs('#start-work-session').addEventListener('click', () => {
|
qs('#start-work-session').addEventListener('click', () => {
|
||||||
|
|
@ -3237,6 +3289,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
contextPoller.start();
|
contextPoller.start();
|
||||||
flushIssueOutbox();
|
flushIssueOutbox();
|
||||||
|
flushAuthoredOutbox();
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
contextPoller.setVisible(!document.hidden);
|
contextPoller.setVisible(!document.hidden);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ function createNotificationReader({
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNotificationReplier({
|
function createNotificationReplier({
|
||||||
post, storage, onStatus,
|
post, storage, onStatus, authoredOutbox,
|
||||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||||
}) {
|
}) {
|
||||||
let pending = false;
|
let pending = false;
|
||||||
|
|
@ -380,7 +380,15 @@ function createNotificationReplier({
|
||||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||||
onStatus('Reply posted. You can mark this update read when ready.');
|
onStatus('Reply posted. You can mark this update read when ready.');
|
||||||
return result;
|
return result;
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
const status = Number(error?.status || 0);
|
||||||
|
if (authoredOutbox && (!status || status >= 500)) {
|
||||||
|
authoredOutbox.enqueue({
|
||||||
|
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
|
||||||
|
});
|
||||||
|
onStatus('Queued for sync when the connection returns.');
|
||||||
|
return { queued: true };
|
||||||
|
}
|
||||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const CACHE = 'stackchain-dashboard-shell-v5';
|
const CACHE = 'stackchain-dashboard-shell-v6';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
@ -12,6 +12,7 @@ const SHELL = [
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
BASE + 'static/drafts.js',
|
BASE + 'static/drafts.js',
|
||||||
BASE + 'static/issue-outbox.js',
|
BASE + 'static/issue-outbox.js',
|
||||||
|
BASE + 'static/authored-outbox.js',
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
BASE + 'static/pick-work.js',
|
BASE + 'static/pick-work.js',
|
||||||
|
|
|
||||||
136
tests/test_authored_outbox.py
Normal file
136
tests/test_authored_outbox.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.views import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str):
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const calls = [];
|
||||||
|
const outbox = createAuthoredOutbox({{
|
||||||
|
storage, now:() => 1234,
|
||||||
|
fetchJson:async (url, options) => {{
|
||||||
|
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||||
|
return {{id:calls.length}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}});
|
||||||
|
outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
|
||||||
|
outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
|
||||||
|
const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
|
||||||
|
outbox.flush().then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert [item["kind"] for item in output["persisted"]] == [
|
||||||
|
"issue-comment", "pull-comment", "update-reply"
|
||||||
|
]
|
||||||
|
assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"]
|
||||||
|
assert output["calls"] == [
|
||||||
|
{"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}},
|
||||||
|
{"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}},
|
||||||
|
{"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}},
|
||||||
|
]
|
||||||
|
assert len(output["result"]["confirmed"]) == 3
|
||||||
|
assert output["remaining"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
let phase = 'permanent';
|
||||||
|
const calls = [];
|
||||||
|
const outbox = createAuthoredOutbox({{
|
||||||
|
storage,
|
||||||
|
fetchJson:async (_url, options) => {{
|
||||||
|
const body = JSON.parse(options.body).body; calls.push(body);
|
||||||
|
if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
|
||||||
|
if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }}
|
||||||
|
return {{id:calls.length}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
|
||||||
|
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
|
||||||
|
outbox.flush().then(async first => {{
|
||||||
|
phase='transient';
|
||||||
|
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
|
||||||
|
const second=await outbox.flush();
|
||||||
|
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
|
||||||
|
}});
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["calls"] == ["Bad", "Good", "Later"]
|
||||||
|
assert len(output["first"]["confirmed"]) == 1
|
||||||
|
assert output["remaining"][0]["id"] == output["badId"]
|
||||||
|
assert output["remaining"][0]["status"] == "attention"
|
||||||
|
assert output["remaining"][0]["error"] == "Reply rejected"
|
||||||
|
assert output["remaining"][1]["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
|
||||||
|
const outbox=createAuthoredOutbox({{
|
||||||
|
storage, createOperationId:()=> 'new-' + (++sequence),
|
||||||
|
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
|
||||||
|
}});
|
||||||
|
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
|
||||||
|
const edited=outbox.update(item.id,{{body:'New'}});
|
||||||
|
const flush=outbox.flush(); const retry=outbox.retry(item.id); release();
|
||||||
|
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["edited"]["body"] == "New"
|
||||||
|
assert output["edited"]["operationId"] == "new-1"
|
||||||
|
assert output["calls"] == 1
|
||||||
|
assert output["remaining"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const outbox=createAuthoredOutbox({{storage}});
|
||||||
|
const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||||
|
const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||||
|
process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["first"] == output["second"]
|
||||||
|
assert len(output["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/authored-outbox.js"></script>' in html
|
||||||
|
assert "createAuthoredOutbox({ storage: localStorage" in html
|
||||||
|
assert "window.addEventListener('online', flushAuthoredOutbox)" in html
|
||||||
|
assert "authoredOutbox.enqueue" in html
|
||||||
|
assert "authoredOutbox.retry(item.outbox_id)" in html
|
||||||
|
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||||
|
assert "if (result?.queued)" in html
|
||||||
|
|
@ -84,6 +84,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
"/dashboard/static/drafts.js",
|
"/dashboard/static/drafts.js",
|
||||||
"/dashboard/static/issue-outbox.js",
|
"/dashboard/static/issue-outbox.js",
|
||||||
|
"/dashboard/static/authored-outbox.js",
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
"/dashboard/static/pick-work.js",
|
"/dashboard/static/pick-work.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user