Merge pull request 'Bind offline outboxes to their originating Gitea account' (#239) from timmy/238-account-bound-outboxes into main
This commit is contained in:
commit
9289434ad2
|
|
@ -1,4 +1,4 @@
|
|||
function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', 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)
|
||||
|
|
@ -9,17 +9,19 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
|||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
}
|
||||
|
||||
function enqueue(message) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
|
|
@ -34,6 +36,7 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
|||
number: Number(message.number || 0),
|
||||
notificationId: Number(message.notificationId || 0),
|
||||
body: String(message.body || ''),
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
|
|
@ -75,7 +78,8 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
|||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
async function sendItem(item) {
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const request = (async () => {
|
||||
try {
|
||||
|
|
@ -108,23 +112,30 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
|||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flush(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
const outcome = await sendItem(item);
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
if (outcome.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function retry(id) {
|
||||
async function retry(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
const queued = item.status === 'attention' ? update(id, item) : item;
|
||||
const outcome = await sendItem(queued);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
|
||||
const indexKey = 'stackchain.draft-index.v1';
|
||||
|
||||
function readIndex() {
|
||||
|
|
@ -101,10 +101,14 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
|||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
).map(item => ({
|
||||
).map(item => {
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
|
|
@ -113,20 +117,28 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
|||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
}));
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function parseAuthoredOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
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;
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
|
|
@ -136,6 +148,10 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
|||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: item.body,
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
|
|
|
|||
|
|
@ -847,6 +847,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let bulkMarkPending = false;
|
||||
let reviewHandoffPending = false;
|
||||
let editingOutboxId = null;
|
||||
let confirmedOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
|
|
@ -876,8 +878,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||
const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||
const issueOutbox = createIssueOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const authoredOutbox = createAuthoredOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const sharedLaunch = {
|
||||
title: shareParams.get('title') || '',
|
||||
|
|
@ -887,7 +893,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
|
|
@ -1242,6 +1248,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
function handleContextError(e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
activeFlushLogin = '';
|
||||
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
|
||||
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
||||
if (!hasContextSnapshot) {
|
||||
|
|
@ -1301,7 +1308,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const list = qs('#my-work-list');
|
||||
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.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
item.kind === 'issue-outbox' ?
|
||||
'<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-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
|
|
@ -1312,7 +1322,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
'<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>';
|
||||
const state = isOutbox ?
|
||||
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
|
|
@ -1344,8 +1356,16 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.outbox_id) return;
|
||||
button.disabled = true;
|
||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
|
||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.copy_text) return;
|
||||
await navigator.clipboard.writeText(item.copy_text);
|
||||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
|
|
@ -2022,8 +2042,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
|
||||
async function flushIssueOutbox() {
|
||||
if (!navigator.onLine || !issueOutbox.list().length) return;
|
||||
applyOutboxResult(await issueOutbox.flush());
|
||||
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
|
||||
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
|
||||
}
|
||||
|
||||
function applyAuthoredOutboxResult(result) {
|
||||
|
|
@ -2036,8 +2056,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
|
||||
async function flushAuthoredOutbox() {
|
||||
if (!navigator.onLine || !authoredOutbox.list().length) return;
|
||||
applyAuthoredOutboxResult(await authoredOutbox.flush());
|
||||
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
|
||||
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
|
||||
}
|
||||
|
||||
function canQueueMessage(error) {
|
||||
|
|
@ -2259,6 +2279,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
if (snapshot.context) {
|
||||
setOfflineWorkMode(false);
|
||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
|
|
@ -2271,6 +2295,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
});
|
||||
updateOfflineWorkControls();
|
||||
}
|
||||
flushIssueOutbox();
|
||||
flushAuthoredOutbox();
|
||||
} else handleContextError(new Error('Context section unavailable'));
|
||||
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
||||
if (eventsFreshness?.revalidating) {
|
||||
|
|
@ -2669,7 +2695,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
closeCreateIssueSheet();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
button.disabled = false;
|
||||
|
|
@ -3222,6 +3248,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
return true;
|
||||
}
|
||||
function showOfflineStatus() {
|
||||
activeFlushLogin = '';
|
||||
offlineStatus.hidden = false;
|
||||
setStatus('Offline');
|
||||
if (!hasContextSnapshot) hydrateOfflineWork();
|
||||
|
|
@ -3248,8 +3275,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
window.addEventListener('online', flushIssueOutbox);
|
||||
window.addEventListener('online', flushAuthoredOutbox);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
|
|
@ -3334,8 +3359,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
);
|
||||
}
|
||||
contextPoller.start();
|
||||
flushIssueOutbox();
|
||||
flushAuthoredOutbox();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
const storageKey = 'stackchain.issue-outbox.v1';
|
||||
const operationId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
|
|
@ -8,16 +8,18 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
|||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
}
|
||||
|
||||
function enqueue(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
|
||||
const item = {
|
||||
|
|
@ -27,6 +29,7 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
|||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
|
|
@ -64,7 +67,8 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
|||
return true;
|
||||
}
|
||||
|
||||
async function sendItem(item) {
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
|
|
@ -98,23 +102,30 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
|||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flush(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
const result = await sendItem(item);
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function retry(id) {
|
||||
async function retry(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
update(id, item);
|
||||
const result = await sendItem({ ...item, status: 'queued' });
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
|
||||
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const CACHE = 'stackchain-dashboard-shell-v7';
|
||||
const CACHE = 'stackchain-dashboard-shell-v8';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
|
|
|
|||
|
|
@ -15,6 +15,34 @@ def run_node(script: str):
|
|||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[
|
||||
{{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}}
|
||||
]}})]]);
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async url => {{calls.push(url); return {{id:1}};}},
|
||||
}});
|
||||
const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}});
|
||||
(async()=>{{
|
||||
const wrong = await outbox.flush('alexander');
|
||||
const right = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["bound"]["ownerLogin"] == "timmy"
|
||||
assert output["wrong"]["blocked"] == 2
|
||||
assert len(output["right"]["confirmed"]) == 1
|
||||
assert [item["id"] for item in output["remaining"]] == ["legacy"]
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
@ -22,7 +50,7 @@ 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,
|
||||
storage, getOwnerLogin:()=>'timmy', now:() => 1234,
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{id:calls.length}};
|
||||
|
|
@ -32,7 +60,7 @@ outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:
|
|||
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()}})));
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
|
|
@ -58,6 +86,7 @@ let phase = 'permanent';
|
|||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
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; }}
|
||||
|
|
@ -67,10 +96,10 @@ const outbox = createAuthoredOutbox({{
|
|||
}});
|
||||
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 => {{
|
||||
outbox.flush('timmy').then(async first => {{
|
||||
phase='transient';
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
|
||||
const second=await outbox.flush();
|
||||
const second=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
|
||||
}});
|
||||
"""
|
||||
|
|
@ -91,12 +120,12 @@ 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),
|
||||
storage, getOwnerLogin:()=>'timmy', 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();
|
||||
const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release();
|
||||
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
|
@ -112,7 +141,7 @@ def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
|
|||
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 outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
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()}}));
|
||||
|
|
@ -128,9 +157,10 @@ 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 "const authoredOutbox = createAuthoredOutbox({" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushAuthoredOutbox();" in html
|
||||
assert "authoredOutbox.enqueue" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id)" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||
assert "if (result?.queued)" in html
|
||||
|
|
|
|||
|
|
@ -121,6 +121,32 @@ process.stdout.write(JSON.stringify(drafts));
|
|||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
|
||||
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
|
||||
]}})],
|
||||
]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
by_id = {item["outbox_id"]: item for item in output}
|
||||
assert by_id["other"]["quarantined"] is True
|
||||
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
|
||||
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
|
||||
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
|
||||
assert by_id["mine"]["quarantined"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
html = await dashboard()
|
||||
|
|
@ -134,3 +160,22 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
|||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let confirmedOwnerLogin = '';" in html
|
||||
assert "let activeFlushLogin = '';" in html
|
||||
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
||||
assert "getCurrentLogin: () => activeFlushLogin" in html
|
||||
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
|
||||
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
|
||||
assert "issueOutbox.flush(activeFlushLogin)" in html
|
||||
assert "authoredOutbox.flush(activeFlushLogin)" in html
|
||||
assert "activeFlushLogin = '';" in html
|
||||
assert 'class="draft-copy"' in html
|
||||
assert "navigator.clipboard.writeText(item.copy_text)" in html
|
||||
assert "item.quarantined" in html
|
||||
|
|
|
|||
|
|
@ -15,6 +15,35 @@ def run_node(script: str):
|
|||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
|
||||
script = f"""
|
||||
const createIssueOutbox = 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 calls = [];
|
||||
let confirmedLogin = 'timmy';
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
|
||||
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
|
||||
confirmedLogin = 'alexander';
|
||||
(async () => {{
|
||||
const mismatch = await outbox.flush('alexander');
|
||||
const unknown = await outbox.retry(queued.id, '');
|
||||
const matched = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["ownerLogin"] == "timmy"
|
||||
assert output["mismatch"]["blocked"] == 1
|
||||
assert output["unknown"]["blocked"] == 1
|
||||
assert len(output["matched"]["confirmed"]) == 1
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
@ -25,7 +54,7 @@ const storage = {{
|
|||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
|
||||
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
|
||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
|
||||
const reloaded = createIssueOutbox({{storage}}).list();
|
||||
|
|
@ -39,7 +68,7 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
|||
]
|
||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
||||
assert all(item["status"] == "queued" for item in output["items"])
|
||||
assert output["stored"]["version"] == 1
|
||||
assert output["stored"]["version"] == 2
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
|
|
@ -51,6 +80,7 @@ let sequence = 0;
|
|||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
createOperationId:() => 'stable-' + (++sequence),
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url, key:options.headers['Idempotency-Key']}});
|
||||
|
|
@ -60,7 +90,7 @@ const outbox = createIssueOutbox({{
|
|||
}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
|
||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
|
||||
outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
|
|
@ -82,7 +112,7 @@ const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),
|
|||
let invalid = true;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, createOperationId:() => 'stable-edit',
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
|
||||
fetchJson:async (_url, options) => {{
|
||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
|
||||
|
|
@ -90,11 +120,11 @@ const outbox = createIssueOutbox({{
|
|||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
|
||||
outbox.flush().then(async () => {{
|
||||
outbox.flush('timmy').then(async () => {{
|
||||
const attention = outbox.list()[0];
|
||||
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
|
||||
invalid = false;
|
||||
const result = await outbox.retry(queued.id);
|
||||
const result = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
|
|
@ -117,12 +147,12 @@ let calls = 0;
|
|||
let release;
|
||||
const gate = new Promise(resolve => {{ release = resolve; }});
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, createOperationId:() => 'single-flight',
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight',
|
||||
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
|
||||
const reconnect = outbox.flush();
|
||||
const sendNow = outbox.retry(queued.id);
|
||||
const reconnect = outbox.flush('timmy');
|
||||
const sendNow = outbox.retry(queued.id, 'timmy');
|
||||
release();
|
||||
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
|
|
@ -138,10 +168,12 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
|
|||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "createIssueOutbox({ storage: localStorage" in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "issueOutbox.enqueue(captureDraft)" in html
|
||||
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
|
||||
assert "navigator.onLine" in html
|
||||
assert "window.addEventListener('online', flushIssueOutbox)" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushIssueOutbox();" in html
|
||||
assert 'class="draft-send"' in html
|
||||
assert 'class="draft-edit"' in html
|
||||
assert 'Queued for sync' in html
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ async function dispatch(name, request) {{
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_identity_bound_outbox_scripts_ship_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v8" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user