feat: add offline delivery center (Closes #481)
This commit is contained in:
parent
b667963968
commit
9dede4bf2a
|
|
@ -166,6 +166,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
async function sendItem(item, currentLogin) {
|
async function sendItem(item, currentLogin) {
|
||||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
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 attemptAt = Number(now());
|
||||||
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
|
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||||
|
} : candidate), false);
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
|
|
@ -209,13 +213,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status || 0);
|
const status = Number(error?.status || 0);
|
||||||
const permanent = status >= 400 && status < 500;
|
const permanent = status >= 400 && status < 500;
|
||||||
|
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||||
if (permanent) {
|
if (permanent) {
|
||||||
write(read().map(candidate => candidate.id === item.id ? {
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
...candidate,
|
...candidate,
|
||||||
status: 'attention',
|
status: 'attention',
|
||||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||||
|
lastAttemptAt: attemptAt,
|
||||||
|
lastAttemptError: attemptError,
|
||||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||||
} : candidate));
|
} : candidate));
|
||||||
|
} else {
|
||||||
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
|
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||||
|
} : candidate));
|
||||||
}
|
}
|
||||||
return { error, transient: !permanent };
|
return { error, transient: !permanent };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||||
.draft-actions button { min-height:44px; width:100%; }
|
.draft-actions button { min-height:44px; width:100%; }
|
||||||
|
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||||
|
.delivery-center h3, .delivery-center p { margin:0; }
|
||||||
|
.delivery-center button { min-height:44px; flex:0 0 auto; }
|
||||||
|
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
|
||||||
|
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
|
||||||
|
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
|
||||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||||
|
|
|
||||||
|
|
@ -1640,7 +1640,9 @@
|
||||||
|
|
||||||
function renderDrafts() {
|
function renderDrafts() {
|
||||||
const list = qs('#my-work-list');
|
const list = qs('#my-work-list');
|
||||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
const deliveryCenter = draftInbox.partition(lastDrafts);
|
||||||
|
const renderDraftCard = item => {
|
||||||
|
const index = lastDrafts.indexOf(item);
|
||||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||||
const isUnfiled = item.kind === 'unfiled-issue';
|
const isUnfiled = item.kind === 'unfiled-issue';
|
||||||
const reviewOutbox = item.outbox_kind === 'pull-review';
|
const reviewOutbox = item.outbox_kind === 'pull-review';
|
||||||
|
|
@ -1674,15 +1676,41 @@
|
||||||
const state = (isOutbox || isUnfiled) ?
|
const state = (isOutbox || isUnfiled) ?
|
||||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||||
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||||||
item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : 'Queued for sync')) + '</span>' +
|
||||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||||
|
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
|
||||||
|
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
|
||||||
return '<article class="my-work-card draft-card">' +
|
return '<article class="my-work-card draft-card">' +
|
||||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state +
|
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||||
}).join('') : '<div class="muted">No unfinished drafts.</div>';
|
};
|
||||||
|
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
|
||||||
|
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||||||
|
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||||||
|
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||||||
|
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong></p></div>' +
|
||||||
|
'<button id="retry-waiting-deliveries" type="button"' +
|
||||||
|
(deliveryCenter.retryable.length && activeFlushLogin ? '' : ' disabled') + '>Retry waiting</button></section>';
|
||||||
|
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
|
||||||
|
'<div class="muted">No queued deliveries.</div>';
|
||||||
|
const draftCards = deliveryCenter.drafts.length ? deliveryCenter.drafts.map(renderDraftCard).join('') :
|
||||||
|
'<div class="muted">No unfinished drafts.</div>';
|
||||||
|
list.innerHTML = deliverySummary + '<section class="draft-section" aria-label="Queued deliveries">' +
|
||||||
|
'<h3>Queued deliveries</h3>' + deliveryCards + '</section>' +
|
||||||
|
'<section class="draft-section" aria-label="Unfinished drafts"><h3>Unfinished drafts</h3>' + draftCards + '</section>';
|
||||||
|
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
|
||||||
|
const button = event.currentTarget;
|
||||||
|
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
|
||||||
|
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
|
||||||
|
applyOutboxResult(issueResult);
|
||||||
|
applyAuthoredOutboxResult(authoredResult);
|
||||||
|
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||||
|
});
|
||||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
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',
|
||||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
status: item.status === 'completion' ? 'completion' :
|
||||||
|
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
|
||||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||||
(item.status === 'completion' ? 'Created · ready to start' :
|
(item.status === 'completion' ? 'Created · ready to start' :
|
||||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||||
|
|
@ -125,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
quarantined,
|
quarantined,
|
||||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||||
|
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||||
|
last_attempt_error: textPreview(item.lastAttemptError),
|
||||||
updated_at: Number(item.queuedAt || 0),
|
updated_at: Number(item.queuedAt || 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -157,7 +160,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
outbox_id: item.id,
|
outbox_id: item.id,
|
||||||
outbox_kind: item.kind,
|
outbox_kind: item.kind,
|
||||||
kind: 'authored-outbox',
|
kind: 'authored-outbox',
|
||||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued'),
|
||||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||||
|
|
@ -170,6 +173,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
quarantined,
|
quarantined,
|
||||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||||
|
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||||
|
last_attempt_error: textPreview(item.lastAttemptError),
|
||||||
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 },
|
||||||
|
|
@ -228,7 +233,21 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
} catch (_error) { return false; }
|
} catch (_error) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { list, discard };
|
function partition(items = list()) {
|
||||||
|
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||||
|
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||||
|
const counts = deliveries.reduce((summary, item) => {
|
||||||
|
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' : 'waiting');
|
||||||
|
summary[state] += 1;
|
||||||
|
return summary;
|
||||||
|
}, { waiting:0, sending:0, attention:0 });
|
||||||
|
const retryable = deliveries.filter(item =>
|
||||||
|
item.status === 'queued' && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||||
|
);
|
||||||
|
return { drafts, deliveries, counts, retryable };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list, discard, partition };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
async function sendItem(item, currentLogin) {
|
async function sendItem(item, currentLogin) {
|
||||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
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 attemptAt = Number(now());
|
||||||
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
|
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||||
|
} : candidate), false);
|
||||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -276,11 +280,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
return { issue, item };
|
return { issue, item };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status || 0);
|
const status = Number(error?.status || 0);
|
||||||
|
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
write(read().map(candidate => candidate.id === item.id ? {
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||||
|
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
|
||||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||||
} : candidate));
|
} : candidate));
|
||||||
|
} else {
|
||||||
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
|
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||||
|
} : candidate));
|
||||||
}
|
}
|
||||||
return { error, transient: !(status >= 400 && status < 500) };
|
return { error, transient: !(status >= 400 && status < 500) };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,30 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||||
assert output["remaining"] == []
|
assert output["remaining"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
let release; const gate = new Promise(resolve => release = resolve);
|
||||||
|
const outbox = createAuthoredOutbox({{
|
||||||
|
storage, getOwnerLogin:()=>'timmy', now:()=>777,
|
||||||
|
fetchJson:async()=>{{await gate; const error=new Error('Network unavailable');error.status=503;throw error;}},
|
||||||
|
}});
|
||||||
|
outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Wait',operationId:'attempt'}});
|
||||||
|
const pending=outbox.flush('timmy');
|
||||||
|
const sending=outbox.list()[0];
|
||||||
|
release();
|
||||||
|
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["sending"]["status"] == "sending"
|
||||||
|
assert output["after"]["status"] == "queued"
|
||||||
|
assert output["after"]["lastAttemptAt"] == 777
|
||||||
|
assert output["after"]["lastAttemptError"] == "Network unavailable"
|
||||||
|
|
||||||
|
|
||||||
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
|
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,36 @@ process.stdout.write(JSON.stringify(drafts));
|
||||||
assert by_id["mine"]["quarantined"] is False
|
assert by_id["mine"]["quarantined"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_inbox_partitions_delivery_center_and_counts_only_safe_waiting_retries():
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
const values = new Map([
|
||||||
|
['stackchain.issue-comment.v1:stackchain/api#17', 'Unfinished comment'],
|
||||||
|
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
|
||||||
|
{{id:'waiting',repository:'o/r',title:'Waiting issue',body:'Body',ownerLogin:'timmy',status:'queued',queuedAt:100,lastAttemptAt:90,lastAttemptError:'Network unavailable'}},
|
||||||
|
{{id:'sending',repository:'o/r',title:'Sending issue',body:'Body',ownerLogin:'timmy',status:'sending',queuedAt:200}},
|
||||||
|
{{id:'attention',repository:'o/r',title:'Broken issue',body:'Body',ownerLogin:'timmy',status:'attention',queuedAt:300}},
|
||||||
|
{{id:'other-user',repository:'o/r',title:'Private issue',body:'Body',ownerLogin:'alexander',status:'queued',queuedAt:400}}
|
||||||
|
]}})],
|
||||||
|
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||||
|
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:4,body:'Maybe sent',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',queuedAt:500}}
|
||||||
|
]}})],
|
||||||
|
]);
|
||||||
|
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 inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||||
|
process.stdout.write(JSON.stringify(inbox.partition()));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert len(output["drafts"]) == 1
|
||||||
|
assert len(output["deliveries"]) == 5
|
||||||
|
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2}
|
||||||
|
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
|
||||||
|
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
|
||||||
|
assert waiting["last_attempt_at"] == 90
|
||||||
|
assert waiting["last_attempt_error"] == "Network unavailable"
|
||||||
|
|
||||||
|
|
||||||
@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()
|
||||||
|
|
@ -245,6 +275,24 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||||
assert "error.code = payload.detail?.code" in html
|
assert "error.code = payload.detail?.code" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_waiting_work():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'class="delivery-center"' in html
|
||||||
|
assert 'id="retry-waiting-deliveries"' in html
|
||||||
|
assert 'Waiting <strong data-delivery-count="waiting">' in html
|
||||||
|
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||||
|
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||||
|
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||||
|
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||||
|
assert "deliveryCenter.retryable.length" in html
|
||||||
|
assert "item.status === 'sending' ? 'Sending'" in html
|
||||||
|
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||||
|
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||||
|
assert '.delivery-center button { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,30 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
|
||||||
assert output["remaining"][0]["status"] == "queued"
|
assert output["remaining"][0]["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||||
|
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)}};
|
||||||
|
let release; const gate = new Promise(resolve => release = resolve);
|
||||||
|
const outbox = createIssueOutbox({{
|
||||||
|
storage, getOwnerLogin:()=>'timmy', createOperationId:()=>'attempt', now:()=>888,
|
||||||
|
fetchJson:async()=>{{await gate;const error=new Error('Gateway timeout');error.status=503;throw error;}},
|
||||||
|
}});
|
||||||
|
outbox.enqueue({{repository:'o/r',title:'Wait',body:'Context'}});
|
||||||
|
const pending=outbox.flush('timmy');
|
||||||
|
const sending=outbox.list()[0];
|
||||||
|
release();
|
||||||
|
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["sending"]["status"] == "sending"
|
||||||
|
assert output["after"]["status"] == "queued"
|
||||||
|
assert output["after"]["lastAttemptAt"] == 888
|
||||||
|
assert output["after"]["lastAttemptError"] == "Gateway timeout"
|
||||||
|
|
||||||
|
|
||||||
def test_foreground_attachment_retry_does_not_create_a_second_issue():
|
def test_foreground_attachment_retry_does_not_create_a_second_issue():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user