Compare commits

...
Author SHA1 Message Date
timmy 63723e7fa3 Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
2026-08-09 22:22:03 +00:00
timmy 740e91a615 perf: precompress immutable dashboard runtime (#431)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:12:00 +00:00
timmy 0363f23cef Merge pull request 'Comment and continue to the next Today item' (#430) from timmy/429-comment-next into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 22:02:02 +00:00
timmy 2cbeba30fd feat: comment and continue Today work (#429)
CI / lint (pull_request) Successful in 50s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:00:14 +00:00
timmy 43595de52f Merge pull request 'Queue offline issue closure and continue Today' (#428) from timmy/427-offline-close-next into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 21:27:59 +00:00
timmy c451cd0bcb feat: queue offline issue closure and continue Today (#427)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 21:26:23 +00:00
timmy ad27db6846 Merge pull request 'Restore background delivery after sign-in following outbox purge' (#426) from timmy/425-resume-outbox-after-purge into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 20:46:12 +00:00
timmy 791082bae2 fix: resume background delivery after purge (#425)
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:44:26 +00:00
timmy d3ed0cca0c Merge pull request 'Queue offline Today reviews and continue to the next item' (#424) from timmy/423-queue-offline-review-next into main
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-09 20:06:04 +00:00
timmy ccb595a081 feat: queue offline Today reviews and continue (#423)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:04:20 +00:00
timmy 7b1c9a608c Merge pull request 'Finish Today items without changing Gitea' (#422) from timmy/421-done-today-next into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 19:39:15 +00:00
timmy 854d040e88 feat: finish Today items without changing Gitea (#421)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:37:40 +00:00
timmy 95baa20eeb Merge pull request 'Queue completed pull request reviews for reconnect delivery' (#420)
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #419
2026-08-09 19:06:15 +00:00
timmy 0cf88d7d7a feat: queue offline pull request reviews (#419)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:04:28 +00:00
rockachopa 969d5567d4 Merge pull request 'Compress the mobile dashboard shell over HTTP' (#418) from timmy/417-http-compression into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 18:35:26 +00:00
31 changed files with 1271 additions and 67 deletions

No files matched your search

+13 -7
View File
@@ -40,7 +40,10 @@ another worker replays a confirmed result instead of posting duplicate content.
five-item Today plan syncs across the operator's devices. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. Delivery or local-admission failure preserves both the draft
and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
@@ -269,11 +272,13 @@ repository catalogs, events, raw patches, and complete API responses are exclude
A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
until reconnection. Cached
requested reviews open in the existing review sheet so file progress, notes, summary,
decision, and inline-comment drafts remain usable under the saved head SHA. Review
submission is never queued: reconnect reloads the current head before enabling submit;
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
**Queue review for reconnect** durably stores that complete SHA-bound review with one
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
@@ -285,7 +290,8 @@ issue comments, pull-request comments, and unread-update replies use bounded loc
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
new issues, issue comments, pull-request comments, unread-update replies, and completed
pull-request reviews after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
+73 -11
View File
@@ -4,7 +4,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
function reviewFingerprint(message) {
return JSON.stringify({
body: String(message.body || ''),
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments : [],
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
if (item.draftKey && item.draftFingerprint &&
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
if (item.progressKey && item.progressFingerprint &&
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
}
function read() {
try {
@@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0) &&
item.expectedHeadSha === String(message.expectedHeadSha || ''));
if (queuedReview) {
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
@@ -45,6 +74,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
draftKey: String(message.draftKey || ''),
progressKey: String(message.progressKey || ''),
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
};
items.push(item);
write(items, mirror);
@@ -95,6 +133,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
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') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
@@ -114,17 +158,32 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
if (item.kind === 'issue-close') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : { body: item.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 };
clearConfirmedReviewState(item);
discard(item.id);
return { result };
} catch (error) {
@@ -196,7 +255,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{
...item,
status: 'attention',
+61 -4
View File
@@ -205,6 +205,7 @@ function createBackgroundIssueSync({
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activePurge = null;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
@@ -242,6 +243,18 @@ function createBackgroundIssueSync({
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
id: item.id, status, kind: 'message',
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') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@@ -260,6 +273,34 @@ function createBackgroundIssueSync({
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
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') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments || [],
}),
},
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
@@ -306,6 +347,11 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item);
try {
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);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
@@ -397,15 +443,26 @@ function createBackgroundIssueSync({
return activeFlush;
}
async function purge() {
function purge() {
if (activePurge) return activePurge;
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
activePurge = (async () => {
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
})();
return activePurge;
}
async function resume() {
const pendingPurge = activePurge;
if (pendingPurge) await pendingPurge;
if (activePurge === pendingPurge) activePurge = null;
purgeRequested = false;
}
return {
flush, send, purge,
flush, send, purge, resume,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
+48
View File
@@ -0,0 +1,48 @@
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
let inFlight = null;
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body);
} catch (error) {
if (!canQueue(error)) throw error;
const admission = await queue({
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository,
number: item.number,
body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
+3 -1
View File
@@ -226,6 +226,8 @@ textarea { resize: vertical; min-height: 120px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.comment-actions button { min-height:44px; width:100%; }
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
@@ -251,7 +253,7 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
.work-session-nav button { min-height:44px; width:100%; }
+185 -6
View File
@@ -120,6 +120,7 @@
let selectedUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
let issueConversation = null;
let issueTrigger = null;
@@ -710,6 +711,9 @@
document.querySelectorAll('[data-work-session-next]').forEach(button => {
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
});
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
button.hidden = !workSession.checkpointed();
});
},
onFinish: () => {
closeOpenWorkSheets();
@@ -764,6 +768,67 @@
warm: warmTodayOffline,
});
const selectedSessionItem = kind => ({
issue: selectedIssue,
pull: selectedPull,
review: selectedReview,
update: selectedUpdate,
})[kind] || null;
const completeTodayItem = createTodayCompletion({
todayWork,
todaySync,
workSession,
refresh: () => refreshMyWorkView({ reconcileSession:false }),
warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; },
});
function setCommentNextVisibility(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
}
const issueCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await issueController.comment(item, body);
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const pullCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await pullController.comment(item, body);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options),
});
function reviewingActiveTodayItem() {
return workSession.checkpointed();
}
function acceptClaimedIssue(confirmed) {
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
@@ -1038,7 +1103,7 @@
function setOfflineDetailControls(kind) {
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',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
@@ -1227,6 +1292,8 @@
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue';
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 outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
@@ -1238,10 +1305,18 @@
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</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>' :
item.kind === 'authored-outbox' ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
reviewOutbox && item.status === 'attention' ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</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>' :
item.kind === 'authored-outbox' && !reviewOutbox ?
'<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-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
@@ -1656,6 +1731,7 @@
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
issueConversation = null;
issueTrigger = trigger;
@@ -1694,11 +1770,14 @@
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
setCommentNextVisibility('issue');
qs('#edit-issue-content').disabled = true;
qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = '';
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();
try {
const detail = offlineDetail || await issueController.load(item);
@@ -1747,6 +1826,7 @@
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueOffline = false;
selectedIssueDetail = null;
issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
@@ -1853,6 +1933,7 @@
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
setCommentNextVisibility('pull');
qs('#close-pull-sheet').focus();
try {
const detail = offlineDetail || await pullController.load(item);
@@ -2355,7 +2436,8 @@
qs('#review-submit-status').textContent = '';
qs('#continue-review-to-merge').hidden = true;
qs('#submit-review').disabled = true;
qs('#submit-review').textContent = offlineReview ? 'Reconnect to validate & submit' : 'Submit review';
qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' :
(offlineReview ? 'Queue review for reconnect' : 'Submit review');
closeInlineComposer();
draft = null;
reviewFiles = [];
@@ -2422,7 +2504,7 @@
qs('#review-sheet-status').textContent = offlineReview ?
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = offlineReview;
qs('#submit-review').disabled = false;
} catch (error) {
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
@@ -3309,6 +3391,43 @@
qs('#issue-milestone').disabled = false;
}
});
async function submitCommentAndNext(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
if (!item || !workSession.checkpointed(item)) return;
const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim();
if (!body) {
status.textContent = 'Write a comment before posting.';
textarea.focus();
return;
}
const postButton = qs('#send-' + kind + '-comment');
const nextButton = qs('#send-' + kind + '-comment-next');
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
item.repository + '#' + item.number + ':operation');
postButton.disabled = true;
nextButton.disabled = true;
status.textContent = 'Posting comment and opening next…';
try {
const result = await controller.submit(item, body, operationId);
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
else status.textContent = 'Saved for next launch; background delivery unavailable.';
} catch (error) {
status.textContent = error.message + ' Your draft and Today position are safe; retry.';
textarea.focus();
} finally {
postButton.disabled = false;
nextButton.disabled = false;
}
}
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
@@ -3427,6 +3546,26 @@
const closing = selectedIssue;
const button = qs('#close-issue');
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…';
try {
await issueController.close(selectedIssue);
@@ -3633,6 +3772,41 @@
)) return;
const button = qs('#submit-review');
button.disabled = true;
if (offlineReview) {
const queuedTodayReview = reviewingActiveTodayItem();
qs('#review-submit-status').textContent = 'Queueing review safely…';
try {
await authoredOutbox.enqueueDurably({
kind: 'pull-review',
repository: selectedReview.repository,
number: selectedReview.number,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
decision: snapshot.decision,
expectedHeadSha: selectedReviewHead,
comments: snapshot.comments,
draftKey: draft.storageKey,
progressKey: progress?.storageKey || '',
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
});
if (queuedTodayReview) {
const advanced = completeTodayItem(selectedReview, {
successMessage: 'Review queued. Next Today item opened.',
failureMessage: 'Review queued, but Today still needs completion.',
});
if (!advanced) {
qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.';
}
} else {
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
button.disabled = false;
button.focus();
}
return;
}
qs('#review-submit-status').textContent = 'Submitting review…';
try {
const item = selectedReview;
@@ -3890,6 +4064,11 @@
document.querySelectorAll('[data-work-session-next]').forEach(button =>
button.addEventListener('click', () => workSession.next())
);
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
button.addEventListener('click', () =>
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
)
);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
+15 -3
View File
@@ -139,22 +139,34 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const isReview = item.kind === 'pull-review';
const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
String(comment.path || 'File') + ' · ' +
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
': ' + String(comment.body || '')
).filter(Boolean) : [];
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
copy_text: copyText,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
+15 -2
View File
@@ -269,7 +269,10 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="issue-planning" id="issue-planning">
@@ -333,6 +336,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="issue" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -448,6 +452,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="update" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -468,7 +473,10 @@
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="pull-review" id="pull-review">
@@ -488,6 +496,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="pull" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -552,6 +561,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="review" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -590,12 +600,15 @@
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-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/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/comment-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
+1 -1
View File
@@ -563,7 +563,7 @@ function createWorkSession({
return {
active: () => running,
checkpointed: () => running && durable,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen() {
+19
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;
+5 -2
View File
@@ -169,7 +169,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed, clear };
return { snapshot, markReviewed, clear, storageKey: key };
}
function createDraft({ storage, repository, number, headSha, files }) {
@@ -267,7 +267,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
return {
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
storageKey: key,
};
}
function formatFeedback(draft, files) {
+8 -2
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v67';
const CACHE = 'stackchain-dashboard-shell-v72';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -21,12 +21,15 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-completion.js',
BASE + 'static/comment-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
@@ -216,7 +219,10 @@ self.addEventListener('sync', event => {
});
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
})());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
}
+17
View File
@@ -0,0 +1,17 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refresh();
warm();
workSession.complete();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+4 -2
View File
@@ -25,7 +25,7 @@ def _quality(accept_encoding: str, coding: str) -> float:
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
@@ -36,7 +36,9 @@ class NegotiatedGZipMiddleware:
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
if _quality(accepted, "gzip") > 0:
path = scope.get("path", "").rsplit("/", 1)[-1]
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
+4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import gzip
import hashlib
import re
from dataclasses import dataclass
@@ -16,6 +17,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
page_sources: tuple[str, ...]
@@ -38,6 +40,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
raise ValueError("dashboard entry document has no page scripts")
runtime = _bundle(frontend_dir, sources)
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
dashboard_html = SCRIPT_TAG.sub("", source_html)
@@ -63,6 +66,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_gzip_bytes=runtime_gzip,
runtime_digest=digest,
runtime_name=runtime_name,
page_sources=sources,
+14 -4
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from src.frontend_bundle import build_frontend
from src.compression import _quality
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
@@ -51,11 +52,20 @@ async def service_worker() -> Response:
@router.get("/runtime-{digest}.js")
async def runtime_bundle(digest: str) -> Response:
async def runtime_bundle(digest: str, request: Request) -> Response:
if digest != FRONTEND_BUILD.runtime_digest:
raise HTTPException(status_code=404, detail="Runtime revision not found")
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
return Response(
FRONTEND_BUILD.runtime_bytes,
(
FRONTEND_BUILD.runtime_gzip_bytes
if accepts_gzip
else FRONTEND_BUILD.runtime_bytes
),
media_type="application/javascript",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept-Encoding",
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
+125
View File
@@ -77,6 +77,85 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
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 {{id:44}}; }},
}});
let queued;
try {{
queued = outbox.enqueue({{
kind:'pull-review', repository:'stackchain/web', number:8, operationId:'review-op',
body:'Looks good', decision:'approve', expectedHeadSha:'abc123',
comments:[{{path:'app.js',body:'Nice',new_position:4}}],
draftKey:'stackchain.review-draft.v1:stackchain/web#8@abc123',
progressKey:'stackchain.review-progress.v1:stackchain/web#8@abc123',
draftFingerprint:'draft', progressFingerprint:'progress',
}});
}} catch (error) {{
process.stdout.write(JSON.stringify({{error:error.message}}));
}}
if (queued) {{
storage.setItem(queued.draftKey, 'draft'); storage.setItem(queued.progressKey, 'progress');
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued, calls:calls.map(call=>({{url:call.url,key:call.options.headers['Idempotency-Key'],body:JSON.parse(call.options.body)}})),
result, remaining:outbox.list(), draft:storage.getItem(queued.draftKey), progress:storage.getItem(queued.progressKey),
}})));
}}
"""
output = run_node(script)
assert "error" not in output, output.get("error")
assert output["queued"]["kind"] == "pull-review"
assert output["queued"]["comments"] == [{"path": "app.js", "body": "Nice", "new_position": 4}]
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/web/pulls/8/review",
"key": "review-op",
"body": {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
},
}]
assert output["remaining"] == []
assert output["draft"] 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():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -183,6 +262,52 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
assert len(output["items"]) == 1
def test_authored_outbox_deduplicates_pull_review_for_same_head_after_reload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();let sequence=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const make=()=>createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-' + (++sequence)}});
const first=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
const second=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
process.stdout.write(JSON.stringify({{first,second,items:make().list()}}));
"""
output = run_node(script)
assert output["first"] == output["second"]
assert len(output["items"]) == 1
def test_authored_outbox_rejects_changed_feedback_while_same_head_is_queued():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'First'}});
let error='';try{{outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'request_changes',body:'Changed'}});}}catch(e){{error=e.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
"""
output = run_node(script)
assert output["error"] == "A review for this saved head is already queued. Open Drafts to inspect or discard it first."
assert len(output["items"]) == 1
def test_confirmed_review_keeps_draft_edits_made_after_it_was_queued():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const draftKey='draft-key';const progressKey='progress-key';storage.setItem(draftKey,'old-draft');storage.setItem(progressKey,'old-progress');
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',fetchJson:async()=>({{id:1}})}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',body:'Queued',draftKey,progressKey,draftFingerprint:'old-draft',progressFingerprint:'old-progress'}});
storage.setItem(draftKey,'newer-draft');storage.setItem(progressKey,'newer-progress');
outbox.flush('timmy').then(()=>process.stdout.write(JSON.stringify({{draft:storage.getItem(draftKey),progress:storage.getItem(progressKey)}})));
"""
output = run_node(script)
assert output == {"draft": "newer-draft", "progress": "newer-progress"}
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+108
View File
@@ -203,6 +203,69 @@ const fetchJson = async (url, options = {{}}) => {{
assert output["result"]["confirmed"] == [{"id": 42}]
def test_closed_app_sync_delivers_pull_review_with_stable_identity_and_full_payload():
authored = {
"id": "review-op", "operationId": "review-op", "ownerLogin": "timmy", "status": "queued",
"kind": "pull-review", "repository": "stackchain/web", "number": 8,
"body": "Looks good", "decision": "approve", "expectedHeadSha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
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'}}:{{id:45}};}};
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/web/pulls/8/review"
assert mutation["options"]["headers"]["Idempotency-Key"] == "review-op"
assert json.loads(mutation["options"]["body"]) == {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
assert output["result"]["confirmed"] == [{"id": 45}]
assert output["result"]["receipts"] == [{
"id": "review-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/review/stackchain/web/8",
}]
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():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -778,6 +841,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{
assert "error" not in output
def test_resume_after_purge_reopens_background_delivery_with_fresh_identity():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state={{closed:0,identityLookups:0,mutations:0,keys:[]}};
let queued={{
id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard',
title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login',
}};
const store={{
close:async()=>{{state.closed+=1;}},
claimBatch:async login=>{{
if(queued?.ownerLogin!==login)return [];
const claimed=queued;queued=null;return [claimed];
}},
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}}
state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']);
return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
await sync.purge();
const blocked=await sync.flush();
await sync.resume();
const delivered=await sync.flush();
process.stdout.write(JSON.stringify({{state,blocked,delivered}}));
}})();
"""
output = run_node(script)
assert output["state"] == {
"closed": 1,
"identityLookups": 1,
"mutations": 1,
"keys": ["op-after-login"],
}
assert output["blocked"]["login"] == ""
assert output["delivered"]["login"] == "timmy"
assert output["delivered"]["confirmed"] == [
{"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"}
]
@pytest.mark.anyio
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
calls = 0
+212
View File
@@ -0,0 +1,212 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_comment_and_next_posts_then_completes_current_today_item_once():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
let finishPost;
const controller = createCommentNext({{
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
queue: () => {{ throw new Error('must not queue'); }},
canQueue: () => false,
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
const first = controller.submit(item, 'Handoff ready');
const second = controller.submit(item, 'Handoff ready');
if (first !== second) throw new Error('submission was not single-flight');
finishPost({{id:7, body:'Handoff ready'}});
(async () => {{
const result = await first;
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 7, "body": "Handoff ready"},
"completed": True,
},
"calls": ["post:429:Handoff ready", "complete:429"],
"busy": False,
}
def test_comment_and_next_advances_after_durable_offline_admission():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = new Error('offline'); retryable.status = 503;
const controller = createCommentNext({{
post: () => Promise.reject(retryable),
canQueue: error => error.status === 503,
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
process.stdout.write(JSON.stringify({{result, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{
"kind": "pull-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "Please review",
"operationId": "op-12",
},
"complete:12",
],
}
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
let operationId = '';
let queued;
const controller = createCommentNext({{
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
canQueue: () => true,
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
complete: () => true,
}});
(async () => {{
const result = await controller.submit(
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
);
process.stdout.write(JSON.stringify({{result, queued}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["result"]["accepted"] is True
assert output["result"]["delivery"] == "saved"
assert output["queued"]["operationId"] == "post-attempt-id"
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const controller = createCommentNext({{
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
accept: item => calls.push(`clear:${{item.number}}`),
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
process.stdout.write(JSON.stringify(calls));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const failures = [];
const permanent = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
canQueue: () => false,
queue: () => Promise.resolve({{durable:true}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const notDurable = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
canQueue: () => true,
queue: () => Promise.resolve({{durable:false, background:false}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const cannotComplete = createCommentNext({{
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
complete: () => false,
}});
(async () => {{
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
}}
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
process.stdout.write(JSON.stringify({{failures, partial}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
"partial": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 9},
"completed": False,
},
}
def test_today_checkpoint_only_matches_the_current_session_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'issue', repository:'r', number:1}},
{{kind:'pull', repository:'r', number:2}},
];
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
}});
session.start();
process.stdout.write(JSON.stringify({{
current: session.checkpointed(items[0]),
other: session.checkpointed(items[1]),
generic: session.checkpointed(),
}}));
"""
assert json.loads(run_node(script)) == {
"current": True,
"other": False,
"generic": True,
}
@pytest.mark.anyio
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
html = await dashboard()
assert 'id="send-issue-comment-next"' in html
assert 'id="send-pull-comment-next"' in html
assert html.count('>Comment &amp; next</button>') == 2
assert "setCommentNextVisibility('issue')" in html
assert "setCommentNextVisibility('pull')" in html
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.comment-actions button { min-height:44px;' in html
+44
View File
@@ -121,6 +121,48 @@ process.stdout.write(JSON.stringify(drafts));
assert output[1]["label"] == "Queued issue"
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
decision:'approve',expectedHeadSha:'abc',comments:[{{path:'app.js',body:'Nice',new_position:4}}],ownerLogin:'timmy',status:'queued',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"] == "Queued review"
assert output["outbox_kind"] == "pull-review"
assert output["title"] == "stackchain/web#8"
assert output["route"] == {"kind": "review", "repository": "stackchain/web", "number": 8}
assert output["copy_text"] == "Looks good\n\nInline feedback\napp.js · new line 4: Nice"
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():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
@@ -197,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" 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 "error.code = payload.detail?.code" in html
+44 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import httpx
import pytest
from starlette.requests import Request
from src import main
from src.frontend_bundle import build_frontend
@@ -21,6 +22,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_name == second.runtime_name
assert first.runtime_bytes == second.runtime_bytes
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
@@ -52,11 +55,46 @@ async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert int(response.headers["content-length"]) <= 100 * 1024
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
async def test_runtime_route_returns_the_precomputed_gzip_representation():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"gzip")]}
)
response = await runtime_bundle(build.runtime_digest, request)
assert response.body == build.runtime_gzip_bytes
assert response.headers["content-encoding"] == "gzip"
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
assert response.headers["vary"] == "Accept-Encoding"
@pytest.mark.anyio
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
class UnexpectedDynamicCompression:
def __init__(self, *args, **kwargs):
raise AssertionError("immutable runtime entered dynamic compression")
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
@@ -108,9 +146,13 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules()
@pytest.mark.anyio
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"identity")]}
)
html = await dashboard()
bundle = await runtime_bundle(build.runtime_digest)
bundle = await runtime_bundle(build.runtime_digest, request)
worker = await service_worker()
assert build.runtime_name in html
+1 -1
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():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v67" in worker
assert "stackchain-dashboard-shell-v72" in worker
+1 -1
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]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v67" in worker
assert "stackchain-dashboard-shell-v72" in worker
+13 -1
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():
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(
"qs('#close-pull-sheet').addEventListener", 1
)[0]
@@ -4205,6 +4206,17 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
assert '.review-diff' in html and 'overflow-x:auto' in html
@pytest.mark.anyio
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
html = await dashboard()
assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
assert "reviewOutbox && item.status === 'attention'" in html
assert ">Open current review</button>" in html
assert ">Copy feedback</button>" in html
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
@pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
html = await dashboard()
+35
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
+48 -3
View File
@@ -264,7 +264,7 @@ async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_cont
@pytest.mark.anyio
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offline():
html = await dashboard()
assert "item.is_review ? reviewController.load(item)" in html
@@ -272,6 +272,51 @@ async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
assert "cachedDetail || await reviewController.load(selectedReview)" in html
assert "Offline review · saved " in html
assert "Reconnect to validate & submit" in html
assert "qs('#submit-review').disabled = offlineReview;" in html
assert "Queue review for reconnect" in html
assert "kind: 'pull-review'" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "expectedHeadSha: selectedReviewHead" in html
assert "comments: snapshot.comments" in html
assert "draftKey: draft.storageKey" in html
assert "progressKey: progress?.storageKey" in html
assert "draftFingerprint: localStorage.getItem(draft.storageKey) || ''" in html
assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html
assert "Review queued · it will submit after reconnect." in html
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
@pytest.mark.anyio
async def test_offline_today_review_queues_durably_before_completing_and_advancing():
html = await dashboard()
assert "function reviewingActiveTodayItem()" in html
assert "offlineReview && reviewingActiveTodayItem() ? 'Queue review & next'" in html
handler = html.split("qs('#submit-review').addEventListener('click'", 1)[1].split(
"qs('#continue-review-to-merge').addEventListener", 1
)[0]
admission = handler.index("await authoredOutbox.enqueueDurably")
completion = handler.index("completeTodayItem(selectedReview")
assert admission < completion
assert "successMessage: 'Review queued. Next Today item opened.'" in handler
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
assert "if (!advanced)" 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
+1 -1
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():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+38 -11
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -47,8 +47,9 @@ const context = {{
}},
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; }},
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
@@ -121,16 +122,24 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -138,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():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -154,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -232,6 +241,21 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
assert result["backgroundFlushes"] == 1
def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing():
result = run_worker_scenario(
"""
const replies = [];
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]);
await dispatchMessage({type:'stackchain-resume-outbox'});
process.stdout.write(JSON.stringify({state,replies}));
"""
)
assert result["replies"] == [{"ok": True}]
assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"]
assert result["state"]["backgroundResumes"] == 1
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario(
"""
@@ -334,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():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -365,12 +389,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-issue-close.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/comment-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
+1 -1
View File
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/today-sync.js'" in source
+114
View File
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
@@ -87,6 +89,102 @@ process.stdout.write(JSON.stringify([
]
def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const calls = [];
const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}};
const complete = createTodayCompletion({{
todayWork: {{
identity: item => `issue:${{item.repository}}:${{item.number}}:`,
remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }},
}},
todaySync: {{
enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }},
flush: () => calls.push('flush'),
}},
refresh: () => calls.push('refresh'),
warm: () => calls.push('warm'),
workSession: {{ complete: () => calls.push('advance') }},
announce: message => calls.push(`announce:${{message}}`),
}});
const completed = complete(current);
process.stdout.write(JSON.stringify({{completed, calls}}));
"""
assert json.loads(run_node(script)) == {
"completed": True,
"calls": [
"remove:421",
"sync:remove:issue:stackchain/dashboard:421:",
"flush",
"refresh",
"warm",
"advance",
"announce:Done for Today. The Gitea item is unchanged.",
],
}
def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const calls = [];
const complete = createTodayCompletion({{
todayWork: {{identity: () => 'issue:r:1:', remove: () => false}},
todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}},
refresh: () => calls.push('refresh'),
warm: () => calls.push('warm'),
workSession: {{complete: () => calls.push('advance')}},
announce: message => calls.push(message),
}});
const completed = complete({{kind:'issue', repository:'r', number:1}});
process.stdout.write(JSON.stringify({{completed, calls}}));
"""
assert json.loads(run_node(script)) == {
"completed": False,
"calls": ["Could not update Today on this device. Try again."],
}
def test_today_completion_reports_context_specific_success_and_partial_failure():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const messages = [];
let removable = false;
const complete = createTodayCompletion({{
todayWork: {{identity: () => 'review:r:7:', remove: () => removable}},
todaySync: {{enqueue: () => true, flush: () => undefined}},
refresh: () => undefined,
warm: () => undefined,
workSession: {{complete: () => undefined}},
announce: message => messages.push(message),
}});
const options = {{
successMessage: 'Review queued. Next Today item opened.',
failureMessage: 'Review queued, but Today still needs completion.',
}};
const failed = complete({{kind:'review', repository:'r', number:7}}, options);
removable = true;
const completed = complete({{kind:'review', repository:'r', number:7}}, options);
process.stdout.write(JSON.stringify({{failed, completed, messages}}));
"""
assert json.loads(run_node(script)) == {
"failed": False,
"completed": True,
"messages": [
"Review queued, but Today still needs completion.",
"Review queued. Next Today item opened.",
],
}
def test_done_for_today_logic_is_available_in_the_offline_app_shell():
assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text()
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
@@ -183,6 +281,22 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert "Another device filled Today · showing its saved plan." in html
@pytest.mark.anyio
async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action():
html = await dashboard()
assert '<script src="static/today-completion.js"></script>' in html
assert html.count('data-work-session-complete=') == 4
assert html.count('Done for Today &amp; next') == 4
assert "button.hidden = !workSession.checkpointed();" in html
assert "const completeTodayItem = createTodayCompletion({" in html
assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html
assert "document.querySelectorAll('[data-work-session-complete]')" in html
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
@pytest.mark.anyio
async def test_dashboard_activates_today_lifecycle_convergence():
html = await dashboard()