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