Compare commits

..

No commits in common. "ea7f3cf098640535d5790564162201988ef7fd81" and "8df41e2fe72b67dbfa1453c678aaf5fae3a34fee" have entirely different histories.

8 changed files with 14 additions and 381 deletions

View File

@ -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:

View File

@ -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;

View File

@ -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);

View File

@ -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)) { qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); qs('#issue-comment').focus();
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;
} }
@ -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)) { qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation'); qs('#pull-comment').focus();
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;
} }
@ -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);
}); });

View File

@ -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 {

View File

@ -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',

View File

@ -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

View File

@ -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",