Compare commits
No commits in common. "ea7f3cf098640535d5790564162201988ef7fd81" and "8df41e2fe72b67dbfa1453c678aaf5fae3a34fee" have entirely different histories.
ea7f3cf098
...
8df41e2fe7
12
README.md
12
README.md
|
|
@ -87,14 +87,10 @@ 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. New issue captures,
|
API responses and mutations are never cached by the service worker or queued;
|
||||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
submissions still require connectivity. Service-worker upgrades are atomic and
|
||||||
outboxes when connectivity or a retryable server failure prevents delivery. Drafts
|
remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
|
||||||
shows queued and needs-attention messages with explicit send/discard controls; reconnect
|
the same origin.
|
||||||
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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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,32 +118,6 @@ 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();
|
||||||
|
|
@ -158,11 +132,6 @@ 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,7 +719,6 @@ 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>
|
||||||
|
|
@ -831,7 +830,6 @@ 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') || '',
|
||||||
|
|
@ -987,7 +985,6 @@ 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({
|
||||||
|
|
@ -1254,18 +1251,13 @@ 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 = isOutbox ?
|
const state = item.kind === 'issue-outbox' ?
|
||||||
'<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>' +
|
||||||
|
|
@ -1298,8 +1290,7 @@ 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;
|
||||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
|
applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
||||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||||
|
|
@ -1307,7 +1298,6 @@ 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"]');
|
||||||
|
|
@ -1980,25 +1970,6 @@ 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,
|
||||||
|
|
@ -2831,17 +2802,8 @@ 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) {
|
||||||
if (canQueueMessage(error)) {
|
|
||||||
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-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
qs('#issue-comment').focus();
|
qs('#issue-comment').focus();
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -2932,17 +2894,8 @@ 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) {
|
||||||
if (canQueueMessage(error)) {
|
|
||||||
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-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
qs('#pull-comment').focus();
|
qs('#pull-comment').focus();
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -2995,11 +2948,7 @@ 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?.queued) {
|
if (result) {
|
||||||
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();
|
||||||
|
|
@ -3203,7 +3152,6 @@ 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', () => {
|
||||||
|
|
@ -3289,7 +3237,6 @@ 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, authoredOutbox,
|
post, storage, onStatus,
|
||||||
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,15 +380,7 @@ 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-v6';
|
const CACHE = 'stackchain-dashboard-shell-v5';
|
||||||
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,7 +12,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
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,7 +84,6 @@ 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