feat: queue offline issue closure and continue Today (#427)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 21:26:23 +00:00
parent ad27db6846
commit c451cd0bcb
19 changed files with 260 additions and 37 deletions

View File

@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
); );
const pending = new Map(); const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review']); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
function reviewFingerprint(message) { function reviewFingerprint(message) {
return JSON.stringify({ return JSON.stringify({
@ -133,6 +133,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply'; return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
} }
const repository = item.repository.split('/').map(encodeURIComponent).join('/'); const repository = item.repository.split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'pull-review') { if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
} }
@ -155,21 +158,29 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
} }
result = delivery.message; result = delivery.message;
} else { } else {
const body = item.kind === 'pull-review' ? { if (item.kind === 'issue-close') {
body: item.body, result = await fetchJson(endpoint(item), {
decision: item.decision, method: 'PATCH',
expected_head_sha: item.expectedHeadSha, headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
comments: item.comments, });
} : { body: item.body }; if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
result = await fetchJson(endpoint(item), { } else {
method: 'POST', const body = item.kind === 'pull-review' ? {
headers: { body: item.body,
Accept: 'application/json', decision: item.decision,
'Content-Type': 'application/json', expected_head_sha: item.expectedHeadSha,
'Idempotency-Key': item.operationId, comments: item.comments,
}, } : { body: item.body };
body: JSON.stringify(body), result = await fetchJson(endpoint(item), {
}); method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify(body),
});
}
} }
if (!result) return { blocked: true }; if (!result) return { blocked: true };
clearConfirmedReviewState(item); clearConfirmedReviewState(item);

View File

@ -249,6 +249,12 @@ function createBackgroundIssueSync({
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number), route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
}; };
} }
if (item.kind === 'issue-close') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue'; const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) }; return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@ -267,6 +273,15 @@ function createBackgroundIssueSync({
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
} }
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
options: {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
},
};
}
if (item.kind === 'pull-review') { if (item.kind === 'pull-review') {
return { return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review', url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@ -332,6 +347,11 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item); const request = deliveryRequest(item);
try { try {
const delivered = await requestJson(request.url, request.options); const delivered = await requestJson(request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
throw error;
}
await store.complete(item.id, delivered); await store.complete(item.id, delivered);
const receipt = receiptFor(item, 'confirmed', delivered); const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };

View File

@ -120,6 +120,7 @@
let selectedUpdate = null; let selectedUpdate = null;
let updateTrigger = null; let updateTrigger = null;
let selectedIssue = null; let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null; let selectedIssueDetail = null;
let issueConversation = null; let issueConversation = null;
let issueTrigger = null; let issueTrigger = null;
@ -781,6 +782,10 @@
warm: warmTodayOffline, warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; }, announce: message => { qs('#my-work-action-status').textContent = message; },
}); });
const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options),
});
function reviewingActiveTodayItem() { function reviewingActiveTodayItem() {
return workSession.checkpointed(); return workSession.checkpointed();
@ -1060,7 +1065,7 @@
function setOfflineDetailControls(kind) { function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [ const selectors = kind === 'issue' ? [
'#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff', '#edit-issue-content', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date', '#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date', '#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments', '#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
@ -1250,6 +1255,7 @@
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';
const closureOutbox = item.outbox_kind === 'issue-close';
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now'; const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ? const outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' + '<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
@ -1266,7 +1272,8 @@
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' + '<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' : '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
item.kind === 'authored-outbox' && !reviewOutbox ? item.kind === 'authored-outbox' && !reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(closureOutbox ? 'Open issue' : 'Open message') + '</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' + '<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</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>' :
reviewOutbox ? reviewOutbox ?
@ -1686,6 +1693,7 @@
qs('#issue-planning').inert = false; qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false; qs('#issue-handoff').inert = false;
selectedIssue = item; selectedIssue = item;
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null; selectedIssueDetail = null;
issueConversation = null; issueConversation = null;
issueTrigger = trigger; issueTrigger = trigger;
@ -1728,7 +1736,9 @@
qs('#issue-edit-form').hidden = true; qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = ''; qs('#issue-edit-status').textContent = '';
qs('#close-issue').disabled = false; qs('#close-issue').disabled = false;
qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue'; qs('#close-issue').textContent = offlineDetail ?
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
(workSession.active() ? 'Close & next' : 'Close issue');
qs('#close-issue-sheet').focus(); qs('#close-issue-sheet').focus();
try { try {
const detail = offlineDetail || await issueController.load(item); const detail = offlineDetail || await issueController.load(item);
@ -1777,6 +1787,7 @@
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
qs('#issue-sheet').classList.remove('open'); qs('#issue-sheet').classList.remove('open');
selectedIssue = null; selectedIssue = null;
selectedIssueOffline = false;
selectedIssueDetail = null; selectedIssueDetail = null;
issueConversation = null; issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus(); if (issueTrigger?.isConnected) issueTrigger.focus();
@ -3458,6 +3469,26 @@
const closing = selectedIssue; const closing = selectedIssue;
const button = qs('#close-issue'); const button = qs('#close-issue');
button.disabled = true; button.disabled = true;
if (selectedIssueOffline) {
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
try {
const outcome = await closeOfflineIssue(closing);
closeIssueSheet();
refreshMyWorkView({ reconcileSession:false });
if (!outcome.advanced) {
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
} else if (outcome.admission.background) {
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
} else {
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
button.disabled = false;
button.focus();
}
return;
}
qs('#issue-sheet-status').textContent = 'Closing issue…'; qs('#issue-sheet-status').textContent = 'Closing issue…';
try { try {
await issueController.close(selectedIssue); await issueController.close(selectedIssue);

View File

@ -140,6 +140,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
.map(item => { .map(item => {
const isUpdate = item.kind === 'update-reply'; const isUpdate = item.kind === 'update-reply';
const isReview = item.kind === 'pull-review'; const isReview = item.kind === 'pull-review';
const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue'); const routeKind = isReview ? 'review' : (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 inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment => const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
@ -158,8 +159,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
kind: 'authored-outbox', kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued', status: item.status === 'attention' ? 'attention' : 'queued',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' : label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : (item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? 'Queued review' : 'Queued message')), (isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
delivery_state: item.deliveryState, delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository, repository: isUpdate ? '' : item.repository,
title: target, title: target,

View File

@ -594,6 +594,7 @@
<script src="static/background-issue-sync.js"></script> <script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script> <script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script> <script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/notification-read-outbox.js"></script> <script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script> <script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script> <script src="static/offline-today.js"></script>

View File

@ -0,0 +1,19 @@
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
return async function closeOfflineIssue(item) {
const admission = await enqueueDurably({
kind: 'issue-close',
repository: String(item.repository || ''),
number: Number(item.number || 0),
body: '',
operationId: String(createOperationId()).slice(0, 128),
});
const advanced = Boolean(completeToday(item, {
successMessage: 'Issue closure queued. Next Today item opened.',
failureMessage: 'Issue closure queued, but Today still needs completion.',
}));
return { admission, advanced };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v70'; const CACHE = 'stackchain-dashboard-shell-v71';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@ -21,6 +21,7 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js', BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js', BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js', BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/notification-read-outbox.js', BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js', BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js', BASE + 'static/offline-today.js',

View File

@ -126,6 +126,36 @@ if (queued) {{
assert output["progress"] is None assert output["progress"] is None
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{ calls.push({{url,options}}); return {{number:27,state:'closed'}}; }},
}});
const queued = outbox.enqueue({{
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued,calls,result,remaining:outbox.list()
}})));
"""
output = run_node(script)
assert output["queued"]["kind"] == "issue-close"
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
"options": {
"method": "PATCH",
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
},
}]
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
def test_authored_outbox_classifies_failures_and_continues_past_attention_items(): def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f""" script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -236,6 +236,36 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
}] }]
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
authored = {
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)}; const calls=[];
const store = {{
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
assert mutation["options"]["method"] == "PATCH"
assert mutation["options"]["headers"] == {
"Accept": "application/json", "Idempotency-Key": "close-op"
}
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["result"]["receipts"] == [{
"id": "close-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/issue/stackchain/dashboard/27",
}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane(): def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f""" script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item));
assert output["quarantined"] is False assert output["quarantined"] is False
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
}}]}})]]);
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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["label"] == "Issue closure needs attention"
assert output["outbox_kind"] == "issue-close"
assert output["title"] == "stackchain/dashboard#27"
assert output["preview"] == "Closure rejected"
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
assert output["quarantined"] is False
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation(): def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f""" script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))}); const createDraftInbox = require({json.dumps(str(DRAFTS))});
@ -218,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'createDraftInbox({ storage: localStorage' in html assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html assert "Verified not posted — retry" in html
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
assert "payload.detail?.message" in html assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html assert "error.code = payload.detail?.code" in html

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v70" in worker assert "stackchain-dashboard-shell-v71" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v70" in worker assert "stackchain-dashboard-shell-v71" in worker

View File

@ -1672,7 +1672,8 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next(): async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
html = await dashboard() html = await dashboard()
assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split( close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
"qs('#close-pull-sheet').addEventListener", 1 "qs('#close-pull-sheet').addEventListener", 1
)[0] )[0]

View File

@ -0,0 +1,35 @@
import json
import subprocess
from pathlib import Path
OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_offline_issue_close_waits_for_durable_admission_before_advancing_today():
script = f"""
const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))});
const events=[]; let release;
const gate=new Promise(resolve=>release=resolve);
const close=createOfflineIssueClose({{
enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}},
completeToday:item=>{{events.push('complete:' + item.number);return true;}},
}});
const item={{kind:'issue',repository:'stackchain/dashboard',number:27}};
const pending=close(item).then(result=>{{events.push('resolved');return result;}});
Promise.resolve().then(async()=>{{
const before=events.slice();release();const result=await pending;
process.stdout.write(JSON.stringify({{before,events,result}}));
}});
"""
output = run_node(script)
assert output["before"] == ["admit"]
assert output["events"] == ["admit", "durable", "complete:27", "resolved"]
assert output["result"]["advanced"] is True
assert output["result"]["admission"]["background"] is True

View File

@ -301,3 +301,22 @@ async def test_offline_today_review_queues_durably_before_completing_and_advanci
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
assert "if (!advanced)" in handler assert "if (!advanced)" in handler
assert "Review queued, but Today still needs completion." in handler assert "Review queued, but Today still needs completion." in handler
@pytest.mark.anyio
async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
html = await dashboard()
assert '<script src="static/offline-issue-close.js"></script>' in html
assert "const closeOfflineIssue = createOfflineIssueClose({" in html
assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
"qs('#close-pull-sheet').addEventListener", 1
)[0]
admission = handler.index("await closeOfflineIssue(closing)")
sheet_close = handler.index("closeIssueSheet()")
assert admission < sheet_close
assert "Issue closure queued for reconnect." in handler
assert "The issue remains in Today; retry." in handler

View File

@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -139,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -147,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -163,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -358,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source
@ -389,6 +389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js", "/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js", "/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js", "/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-issue-close.js",
"/dashboard/static/notification-read-outbox.js", "/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js", "/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js", "/dashboard/static/offline-today.js",

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v70" in source assert "stackchain-dashboard-shell-v71" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source