Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
055a9dbb2a | ||
|
|
1f7fd093fd | ||
|
|
ff0e47cbe7 | ||
|
|
c2da869c47 | ||
|
|
8ada75ec33 | ||
|
|
6dea5fd1ef | ||
|
|
bf4ce3adad | ||
|
|
34664edb8b | ||
|
|
f92794f571 | ||
|
|
edbe2d76dd | ||
|
|
cbdddcddbe | ||
|
|
de6c9c249a | ||
|
|
19122c69e6 | ||
|
|
2253c40577 | ||
|
|
5c2c0383df | ||
|
|
4e13f5482b | ||
|
|
51278bc86f | ||
|
|
a919242b8f | ||
|
|
56a9a0cba6 | ||
|
|
27fc74ced6 | ||
|
|
f686e2e71e |
No files matched your search
@@ -37,10 +37,18 @@ the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
|
||||
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
|
||||
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
|
||||
offline operations. After a healthy, fully paginated
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
changing a newer Today or Later plan. After a healthy, fully paginated
|
||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||
@@ -160,7 +168,10 @@ Every response also defines the browser execution boundary with a Content Securi
|
||||
Policy that allows scripts only from the dashboard origin, denies framing and
|
||||
plugins, and blocks unused browser capabilities. Keep these response headers when
|
||||
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
|
||||
and stylesheet are same-origin static assets included in the offline PWA shell.
|
||||
is assembled in source order into one content-addressed JavaScript response. Dashboard
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
@@ -202,10 +213,12 @@ below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
|
||||
can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the
|
||||
sections that changed.
|
||||
revision tokens. Each bounded opaque token includes a per-process generation, so a token
|
||||
from another worker or from before a restart cannot suppress different content. The browser
|
||||
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
|
||||
while still returning current freshness and retry metadata. The client retains omitted data
|
||||
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
|
||||
rejected before any upstream work.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
@@ -224,8 +237,11 @@ or changing any Gitea issue or pull request. They automatically return to their
|
||||
existing priority position at the wake time, and **Bring back now** restores them
|
||||
early. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||
and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
|
||||
store at `.stackchain-state/later.sqlite3`.
|
||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||
account plan and reports the conflict instead of silently overwriting it. Unrelated
|
||||
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
|
||||
default durable store at `.stackchain-state/later.sqlite3`.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
|
||||
@@ -180,7 +180,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
complete: (id, deliveredIssue) => update(id, item => ({
|
||||
...item, status: 'sent', claimUntil: 0,
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
@@ -303,7 +306,7 @@ function createBackgroundIssueSync({
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
await store.complete(item.id, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createCardPlanning = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
|
||||
function sync(disclosure) {
|
||||
disclosure.querySelector('summary')?.setAttribute(
|
||||
'aria-expanded', String(disclosure.open)
|
||||
);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
|
||||
disclosures.forEach(disclosure => {
|
||||
if (disclosure.dataset.cardPlanningBound === 'true') return;
|
||||
disclosure.dataset.cardPlanningBound = 'true';
|
||||
sync(disclosure);
|
||||
disclosure.addEventListener('toggle', () => {
|
||||
if (disclosure.open) {
|
||||
disclosures.forEach(other => {
|
||||
if (other === disclosure || !other.open) return;
|
||||
other.open = false;
|
||||
sync(other);
|
||||
});
|
||||
}
|
||||
sync(disclosure);
|
||||
});
|
||||
disclosure.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
disclosure.open = false;
|
||||
sync(disclosure);
|
||||
disclosure.querySelector('summary')?.focus();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { wire };
|
||||
});
|
||||
@@ -1,3 +1,15 @@
|
||||
function buildLiveRevisionQuery(revisions = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
|
||||
['context', 'events', 'notifications'].forEach((section) => {
|
||||
const revision = revisions[section];
|
||||
if (typeof revision === 'string' && tokenPattern.test(revision)) {
|
||||
params.set(section + '_revision', revision);
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
@@ -123,6 +135,8 @@ function createContextPoller({
|
||||
};
|
||||
}
|
||||
|
||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
|
||||
const completed = new Set();
|
||||
|
||||
function available() {
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
if (added === 'full') {
|
||||
announce('Created, but Today changed—remove an item, then add this issue.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
warm();
|
||||
start(issue);
|
||||
announce('Created, added to Today, and ready to work.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
+31
-1
@@ -82,6 +82,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.plan-today-sheet[hidden] { display:none; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
@@ -96,7 +110,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
@@ -105,6 +121,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.card-planning { min-width:0; }
|
||||
.card-planning > summary { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:grid; }
|
||||
.card-planning-actions { display:grid; gap:8px; min-width:0; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -226,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.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[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
@@ -335,6 +356,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:none; }
|
||||
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
|
||||
.plan-today-panel { width:100%; border-left:0; padding:14px; }
|
||||
.plan-today-item { grid-template-columns:1fr; }
|
||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
|
||||
+219
-22
@@ -1,6 +1,7 @@
|
||||
(function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
const mobileComposerViewport = createMobileComposerViewport({
|
||||
viewport: window.visualViewport,
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
@@ -118,6 +119,7 @@
|
||||
let pullConversation = null;
|
||||
let pullReviewState = null;
|
||||
let creatingIssue = false;
|
||||
let createAndStartRequested = false;
|
||||
let findingWork = false;
|
||||
let availablePagination = { page: 1, total: 0, has_more: false };
|
||||
let progress = null;
|
||||
@@ -155,8 +157,9 @@
|
||||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||||
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
||||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||
'Today sync unavailable · changes stay on this device.')));
|
||||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||||
'Today sync unavailable · changes stay on this device.'))));
|
||||
},
|
||||
});
|
||||
todaySync.startLifecycle({ window, document });
|
||||
@@ -186,9 +189,11 @@
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
|
||||
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||
(state === 'pending' ? 'Later saved on this device · sync pending.' :
|
||||
'Later sync unavailable · changes stay on this device.'));
|
||||
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||||
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||
(state === 'pending' ? 'Later saved on this device · sync pending.' :
|
||||
(state === 'expired' ? `Later edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||||
'Later sync unavailable · changes stay on this device.'))));
|
||||
},
|
||||
});
|
||||
laterSync.startLifecycle({ window, document });
|
||||
@@ -321,11 +326,7 @@
|
||||
setClock(); setInterval(setClock, 1000);
|
||||
|
||||
async function fetchLiveSnapshot(revisions = {}, { signal } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(revisions).forEach(([section, revision]) => {
|
||||
if (Number.isInteger(revision) && revision >= 0) params.set(section + '_revision', revision);
|
||||
});
|
||||
const query = params.toString();
|
||||
const query = createContextPoller.buildRevisionQuery(revisions);
|
||||
const res = await fetch('api/v1/live' + (query ? '?' + query : ''), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
@@ -664,12 +665,30 @@
|
||||
openRoutedWork(item, null, { replace:true });
|
||||
}
|
||||
|
||||
const sessionCheckpoint = createWorkSessionCheckpoint({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onError: () => {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'Session recovery could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
function updateWorkSessionActions() {
|
||||
const active = workSession.active();
|
||||
qs('#start-work-session').hidden = active;
|
||||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||||
qs('#end-today-session').hidden = !active;
|
||||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
checkpoint: sessionCheckpoint,
|
||||
checkpointEnabled: () => selectedWorkFilter === 'today',
|
||||
onOpen: openWorkSessionItem,
|
||||
onProgress: state => {
|
||||
updateWorkSessionActions();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + state.index + ' of ' + state.total;
|
||||
@@ -685,10 +704,104 @@
|
||||
closeOpenWorkSheets();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
updateWorkSessionActions();
|
||||
qs('#start-work-session').focus();
|
||||
},
|
||||
});
|
||||
|
||||
const createAndStart = createCreateAndStart({
|
||||
todayWork,
|
||||
todaySync,
|
||||
refresh: refreshMyWorkView,
|
||||
warm: warmTodayOffline,
|
||||
start: item => {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
workSession.start(item);
|
||||
},
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
|
||||
let planTodayTrigger = null;
|
||||
function planTodayItemMarkup(item, selected, index = -1) {
|
||||
const id = todayWork.identity(item);
|
||||
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
|
||||
const title = escapeHtml(item.title || 'Untitled work');
|
||||
const controls = selected ?
|
||||
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
|
||||
'<button type="button" data-plan-add="' + escAttr(id) + '">Add</button>';
|
||||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong></div>' + controls + '</article>';
|
||||
}
|
||||
|
||||
function renderPlanToday() {
|
||||
const state = planToday.snapshot();
|
||||
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected';
|
||||
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
|
||||
planTodayItemMarkup(planToday.item(id), true, index)
|
||||
).join('') : '<p class="muted">No work selected yet.</p>';
|
||||
const candidates = planToday.candidates();
|
||||
qs('#plan-today-candidates').innerHTML = candidates.length ? candidates.map(item =>
|
||||
planTodayItemMarkup(item, false)
|
||||
).join('') : '<p class="muted">All available work is already selected.</p>';
|
||||
document.querySelectorAll('[data-plan-add]').forEach(button => button.addEventListener('click', () => {
|
||||
const result = planToday.toggle(planToday.item(button.dataset.planAdd));
|
||||
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
|
||||
renderPlanToday();
|
||||
}));
|
||||
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
|
||||
planToday.toggle(planToday.item(button.dataset.planRemove));
|
||||
qs('#plan-today-error').textContent = '';
|
||||
renderPlanToday();
|
||||
}));
|
||||
document.querySelectorAll('[data-plan-move]').forEach(button => button.addEventListener('click', () => {
|
||||
planToday.move(button.dataset.planId, button.dataset.planMove);
|
||||
renderPlanToday();
|
||||
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
|
||||
}));
|
||||
}
|
||||
|
||||
function closePlanToday() {
|
||||
planToday.cancel();
|
||||
qs('#plan-today-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
planTodayTrigger?.focus();
|
||||
}
|
||||
|
||||
function saveTodayPlan(ids) {
|
||||
const previous = todayWork.read();
|
||||
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
|
||||
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
|
||||
if (!todayWork.replace(ids)) return false;
|
||||
refreshMyWorkView();
|
||||
todaySync.flush();
|
||||
warmTodayOffline();
|
||||
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order.' : 'Today plan cleared.';
|
||||
return true;
|
||||
}
|
||||
|
||||
const planToday = createPlanToday({
|
||||
identity: item => todayWork.identity(item),
|
||||
limit: todayWork.limit,
|
||||
save: saveTodayPlan,
|
||||
start: () => {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
workSession.start();
|
||||
},
|
||||
});
|
||||
|
||||
function openPlanToday(trigger) {
|
||||
if (!planningOwnerLogin) {
|
||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||
return;
|
||||
}
|
||||
planTodayTrigger = trigger;
|
||||
planToday.open(todayMyWork, activeMyWork);
|
||||
qs('#plan-today-error').textContent = '';
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
renderPlanToday();
|
||||
qs('#cancel-plan-today').focus();
|
||||
}
|
||||
|
||||
const detailDefer = createDetailDefer({
|
||||
laterWork,
|
||||
session: workSession,
|
||||
@@ -888,7 +1001,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
function refreshMyWorkView() {
|
||||
function refreshMyWorkView({ reconcileSession = true } = {}) {
|
||||
lastDrafts = listDrafts();
|
||||
const partitioned = laterWork.partition(lastMyWork, {
|
||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||||
@@ -947,7 +1060,8 @@
|
||||
'No assigned work, review requests, or unread updates.';
|
||||
updateWorkPaginationControls();
|
||||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||||
updateWorkSessionActions();
|
||||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
@@ -969,6 +1083,9 @@
|
||||
const outboxActions = item.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
item.continuation ?
|
||||
'<button class="draft-continue" data-draft-index="' + index + '" type="button">Continue created work</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Dismiss</button>' :
|
||||
item.kind === 'issue-outbox' ?
|
||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
@@ -982,7 +1099,8 @@
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||
const state = (isOutbox || isUnfiled) ?
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||||
item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
@@ -1007,6 +1125,16 @@
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-continue').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
const completion = issueOutbox.pendingCompletions(activeFlushLogin)
|
||||
.find(candidate => candidate.id === item?.outbox_id);
|
||||
if (completion) applyOutboxResult({
|
||||
confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(),
|
||||
});
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-edit').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
@@ -1108,7 +1236,7 @@
|
||||
const todayActions = selectedWorkFilter === 'today' ?
|
||||
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
||||
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
||||
const planningActions = todayActions + laterActions;
|
||||
const planningActions = '<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||||
if (item.is_review) {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
@@ -1122,6 +1250,7 @@
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||
cardPlanning.wire();
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
@@ -1421,6 +1550,7 @@
|
||||
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-sheet').focus();
|
||||
try {
|
||||
const detail = offlineDetail || await issueController.load(item);
|
||||
@@ -1868,6 +1998,7 @@
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#submit-new-issue').disabled = !repositories.length;
|
||||
qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
qs('#create-issue-title').focus();
|
||||
@@ -1887,13 +2018,25 @@
|
||||
qs('#new-issue').focus();
|
||||
}
|
||||
|
||||
function applyOutboxResult(result, openCreated = false) {
|
||||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||||
(result.confirmed || []).forEach(confirmed => {
|
||||
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||
});
|
||||
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
||||
refreshMyWorkView();
|
||||
const startedCompletions = new Set();
|
||||
(result.completions || []).forEach(completion => {
|
||||
if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return;
|
||||
const created = lastMyWork.find(item => item.kind === 'issue' &&
|
||||
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
|
||||
if (!created) return;
|
||||
const outcome = createAndStart.complete(created);
|
||||
if (outcome === 'started' || outcome === 'exists') {
|
||||
issueOutbox.completeIntent(completion.id, activeFlushLogin);
|
||||
startedCompletions.add(created.repository + '#' + created.number);
|
||||
}
|
||||
});
|
||||
if (result.confirmed?.length) {
|
||||
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
|
||||
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
||||
@@ -1901,7 +2044,11 @@
|
||||
const created = lastMyWork.find(item =>
|
||||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||
);
|
||||
if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
|
||||
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
|
||||
if (startCreated && !(result.completions || []).length && created) {
|
||||
const outcome = createAndStart.complete(created);
|
||||
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
|
||||
} else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue'));
|
||||
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
|
||||
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
|
||||
} else {
|
||||
@@ -2652,6 +2799,7 @@
|
||||
});
|
||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
if (!captureDraft.repository || !captureDraft.title) {
|
||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||
@@ -2664,18 +2812,28 @@
|
||||
qs('#create-issue-anyway').focus();
|
||||
return;
|
||||
}
|
||||
if (createAndStartRequested && !createAndStart.available()) {
|
||||
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
|
||||
qs('#create-and-start-issue').focus();
|
||||
return;
|
||||
}
|
||||
const button = qs('#submit-new-issue');
|
||||
const startButton = qs('#create-and-start-issue');
|
||||
button.disabled = true;
|
||||
qs('#create-issue-status').textContent = 'Saving for background delivery…';
|
||||
startButton.disabled = true;
|
||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||
try {
|
||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) :
|
||||
await issueOutbox.enqueueDurably(captureDraft);
|
||||
const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft;
|
||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const queued = admission.item;
|
||||
if (!admission.background) {
|
||||
editingOutboxId = queued.id;
|
||||
refreshMyWorkView();
|
||||
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
||||
button.disabled = false;
|
||||
startButton.disabled = !createAndStart.available();
|
||||
return;
|
||||
}
|
||||
editingOutboxId = null;
|
||||
@@ -2684,10 +2842,14 @@
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
|
||||
if (navigator.onLine) applyOutboxResult(
|
||||
await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested
|
||||
);
|
||||
createAndStartRequested = false;
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
button.disabled = false;
|
||||
startButton.disabled = !createAndStart.available();
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
@@ -3000,8 +3162,14 @@
|
||||
lastMyWork = lastMyWork.filter(item =>
|
||||
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
|
||||
);
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
||||
refreshMyWorkView({ reconcileSession:false });
|
||||
const continuingSession = workSession.active();
|
||||
if (workSession.active()) workSession.complete();
|
||||
if (continuingSession && workSession.active()) {
|
||||
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
|
||||
} else if (!continuingSession) {
|
||||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
@@ -3403,6 +3571,23 @@
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||
});
|
||||
qs('#save-today-plan').addEventListener('click', () => {
|
||||
const result = planToday.commit();
|
||||
if (result === 'saved') closePlanToday();
|
||||
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||
});
|
||||
qs('#save-and-start-today').addEventListener('click', () => {
|
||||
const result = planToday.commit({ start:true });
|
||||
if (result === 'saved') {
|
||||
qs('#plan-today-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
} else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||
});
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
if (!sessionItems.length) {
|
||||
@@ -3411,6 +3596,18 @@
|
||||
}
|
||||
workSession.start();
|
||||
});
|
||||
qs('#resume-today-session').addEventListener('click', () => {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
if (!workSession.resume()) {
|
||||
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
|
||||
updateWorkSessionActions();
|
||||
}
|
||||
});
|
||||
qs('#end-today-session').addEventListener('click', () => {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
qs('#start-work-session').focus();
|
||||
});
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.previous())
|
||||
);
|
||||
|
||||
+5
-3
@@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
@@ -112,9 +112,11 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
|
||||
delivery_state: item.deliveryState,
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
|
||||
@@ -48,7 +48,10 @@
|
||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||
</div>
|
||||
<div class="my-work-actions">
|
||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
<button class="find-work-action" id="find-work" type="button">Find work</button>
|
||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||
</div>
|
||||
@@ -170,6 +173,29 @@
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
|
||||
<section class="plan-today-panel">
|
||||
<div class="plan-today-header">
|
||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
<div class="plan-today-list" id="plan-today-list"></div>
|
||||
</section>
|
||||
<section aria-labelledby="today-candidates-heading">
|
||||
<h3 id="today-candidates-heading">Available My Work</h3>
|
||||
<div class="plan-today-candidates" id="plan-today-candidates"></div>
|
||||
</section>
|
||||
<div class="plan-today-actions">
|
||||
<button id="save-today-plan" type="button">Save plan</button>
|
||||
<button id="save-and-start-today" type="button">Save & start</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
@@ -365,6 +391,7 @@
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -557,7 +584,9 @@
|
||||
<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/plan-today.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
@@ -568,6 +597,7 @@
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/work-route.js"></script>
|
||||
|
||||
@@ -8,13 +8,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
|
||||
coordinator?.notify('issue');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items))
|
||||
@@ -39,6 +39,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -86,6 +87,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
delete updated.error;
|
||||
@@ -147,7 +150,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { issue };
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
@@ -166,16 +169,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (item.status === 'attention' || item.status === 'completion') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
@@ -202,7 +215,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
@@ -214,7 +237,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
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') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
}];
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
@@ -225,7 +253,32 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
function pendingCompletions(currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
if (!login) return [];
|
||||
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
|
||||
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
|
||||
id: item.id,
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
function completeIntent(id, currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
const items = read();
|
||||
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
|
||||
if (!matched) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
+38
-12
@@ -1,5 +1,6 @@
|
||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||
@@ -8,6 +9,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
@@ -105,17 +107,32 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation)
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
records.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => record.operation).filter(operation =>
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
const activeRecords = records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : 0,
|
||||
})).filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
|
||||
@@ -148,13 +165,16 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt };
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('later');
|
||||
@@ -183,9 +203,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let conflictCount = 0;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/later');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
@@ -202,6 +224,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
conflictCount += (plan.rejected_operations || [])
|
||||
.filter(item => item.reason === 'stale_intent').length;
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
@@ -211,7 +235,9 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : 'saved');
|
||||
onStatus?.(pending().length ? 'pending' :
|
||||
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
|
||||
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return true;
|
||||
|
||||
+79
-3
@@ -472,10 +472,67 @@ function workIdentity(item) {
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||
function createWorkSessionCheckpoint({
|
||||
storage,
|
||||
getLogin,
|
||||
onError = () => {},
|
||||
key = 'stackchain.today-session.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
let errorReported = false;
|
||||
const reportError = error => {
|
||||
if (errorReported) return;
|
||||
errorReported = true;
|
||||
onError(error);
|
||||
};
|
||||
const read = () => {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
|
||||
!Number.isInteger(value.index) || value.index < 0) return null;
|
||||
return value;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
read,
|
||||
save(identity, index) {
|
||||
const owner = login();
|
||||
if (!owner) return false;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
if (!read()) return false;
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
const clearCheckpoint = durable;
|
||||
running = false;
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -502,11 +563,25 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
start() {
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resume() {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
return openAt(items, 0);
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||
},
|
||||
start(item = null) {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
reconcile() {
|
||||
if (!running) return false;
|
||||
@@ -611,6 +686,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let openState = false;
|
||||
let draftIds = [];
|
||||
let itemsById = new Map();
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
for (const item of items || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && !unique.has(id)) unique.set(id, item);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
|
||||
}
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!openState) return 'closed';
|
||||
const id = identity?.(item);
|
||||
if (!id) return 'unavailable';
|
||||
itemsById.set(id, item);
|
||||
const index = draftIds.indexOf(id);
|
||||
if (index >= 0) {
|
||||
draftIds.splice(index, 1);
|
||||
return 'removed';
|
||||
}
|
||||
if (draftIds.length >= limit) return 'full';
|
||||
draftIds.push(id);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
function move(id, direction) {
|
||||
const index = draftIds.indexOf(id);
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
|
||||
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
openState = false;
|
||||
draftIds = [];
|
||||
itemsById = new Map();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
if (save?.(ids) === false) return 'unavailable';
|
||||
const first = ids.length ? itemsById.get(ids[0]) : null;
|
||||
close();
|
||||
if (startAfterSave && first) start?.(first);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
}
|
||||
|
||||
function item(id) {
|
||||
return itemsById.get(id) || null;
|
||||
}
|
||||
|
||||
function candidates() {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v54';
|
||||
const CACHE = 'stackchain-dashboard-shell-v60';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
@@ -24,7 +24,9 @@ const SHELL = [
|
||||
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/plan-today.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/later-work.js',
|
||||
@@ -35,6 +37,7 @@ const SHELL = [
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
|
||||
+31
-7
@@ -1,5 +1,6 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
||||
@@ -8,6 +9,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
@@ -97,11 +99,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
|
||||
.filter(record => record?.operation)
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
return records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id))
|
||||
.map(record => record.operation)
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
@@ -136,13 +155,16 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
@@ -169,6 +191,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
@@ -199,7 +222,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Build the content-addressed browser shell served by the dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
runtime_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
page_sources: tuple[str, ...]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
chunks = []
|
||||
for source in sources:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||
source_html = (frontend_dir / "index.html").read_text()
|
||||
sources = tuple(SCRIPT_TAG.findall(source_html))
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
page_sources=sources,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
+94
-53
@@ -2,14 +2,26 @@
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LaterStore:
|
||||
def __init__(self, path: str | Path, *, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -29,12 +41,48 @@ class LaterStore:
|
||||
CREATE TABLE IF NOT EXISTS later_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_item_revisions (
|
||||
login TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, item_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
if "created_at" not in columns:
|
||||
connection.execute("ALTER TABLE later_operations ADD COLUMN created_at REAL")
|
||||
connection.execute(
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
(login, operation_id, now),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM later_operations WHERE login = ? AND created_at < ?",
|
||||
(login, now - self.operation_retention_seconds),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM later_operations WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM later_operations WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.operation_limit),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
@@ -74,54 +122,16 @@ class LaterStore:
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"defer", "restore"}:
|
||||
raise ValueError("unsupported Later action")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
return snapshot
|
||||
|
||||
records = dict(snapshot["records"])
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
return {"revision": revision, "records": records}
|
||||
result = self.apply_batch(login, [{
|
||||
"operation_id": operation_id,
|
||||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply ordered deferrals with one SQLite write transaction."""
|
||||
@@ -136,6 +146,20 @@ class LaterStore:
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
|
||||
# Existing databases predate per-item revisions. Conservatively mark
|
||||
# active deferrals as changed at the latest known plan revision.
|
||||
for item_id in records:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
item_revisions = dict(connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall())
|
||||
batch_start_item_revisions = dict(item_revisions)
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
@@ -156,6 +180,19 @@ class LaterStore:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
base_revision = operation.get("base_revision")
|
||||
if base_revision is None:
|
||||
base_revision = snapshot["revision"]
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
if batch_start_item_revisions.get(item_id, 0) > base_revision:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({
|
||||
"operation_id": operation_id,
|
||||
"reason": "stale_intent",
|
||||
})
|
||||
continue
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
@@ -164,10 +201,14 @@ class LaterStore:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
revision += 1 if changed else 0
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
if changed:
|
||||
item_revisions[item_id] = revision
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
@@ -186,5 +227,5 @@ class LaterStore:
|
||||
"records": records,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": [],
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
+29
-8
@@ -2,6 +2,7 @@ import asyncio
|
||||
import hmac
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Awaitable, Coroutine
|
||||
@@ -37,7 +38,7 @@ from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
from src.views import router as frontend_router
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
@@ -108,6 +109,7 @@ _live_snapshot_refreshing_sections: set[str] = set()
|
||||
_live_section_revisions: dict[str, int] = {
|
||||
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_live_revision_generation = secrets.token_hex(8)
|
||||
_read_notification_ids: set[int] = set()
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
@@ -207,6 +209,7 @@ class TodayOperation(BaseModel):
|
||||
action: Literal["add", "remove", "move"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_move_direction(self):
|
||||
@@ -222,6 +225,7 @@ class LaterOperation(BaseModel):
|
||||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TodayOperationBatch(BaseModel):
|
||||
@@ -587,7 +591,13 @@ async def require_operator_session(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
public = (
|
||||
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
|
||||
path in {
|
||||
"/healthz",
|
||||
"/readyz",
|
||||
"/login",
|
||||
"/manifest.webmanifest",
|
||||
"/" + FRONTEND_BUILD.runtime_name,
|
||||
}
|
||||
or path.startswith("/static/")
|
||||
or (path == "/api/v1/session" and request.method == "POST")
|
||||
)
|
||||
@@ -969,6 +979,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
@@ -1575,7 +1586,7 @@ def _live_snapshot_payload(
|
||||
*,
|
||||
stale: bool,
|
||||
revalidating: bool,
|
||||
known_revisions: dict[str, int | None] | None = None,
|
||||
known_revisions: dict[str, str | None] | None = None,
|
||||
) -> dict:
|
||||
payload = dict(value)
|
||||
now = time.monotonic()
|
||||
@@ -1608,9 +1619,13 @@ def _live_snapshot_payload(
|
||||
"retry_in_seconds": min(retries, default=0),
|
||||
"sections": section_freshness,
|
||||
}
|
||||
payload["revisions"] = dict(_live_section_revisions)
|
||||
revision_tokens = {
|
||||
section: f"{_live_revision_generation}.{revision}"
|
||||
for section, revision in _live_section_revisions.items()
|
||||
}
|
||||
payload["revisions"] = revision_tokens
|
||||
for section, known_revision in (known_revisions or {}).items():
|
||||
if known_revision is None or known_revision != _live_section_revisions[section]:
|
||||
if known_revision is None or known_revision != revision_tokens[section]:
|
||||
continue
|
||||
payload.pop(section, None)
|
||||
if section == "notifications":
|
||||
@@ -1660,9 +1675,15 @@ def _without_read_notifications(snapshot: dict) -> dict:
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot(
|
||||
context_revision: int | None = Query(default=None, ge=0),
|
||||
events_revision: int | None = Query(default=None, ge=0),
|
||||
notifications_revision: int | None = Query(default=None, ge=0),
|
||||
context_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
events_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
notifications_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
) -> JSONResponse:
|
||||
"""Return a freshness-bounded snapshot and share identical upstream loads."""
|
||||
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
|
||||
|
||||
+52
-9
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -10,10 +11,22 @@ class TodayPlanFull(ValueError):
|
||||
|
||||
|
||||
class TodayStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 5,
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -33,12 +46,38 @@ class TodayStore:
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||
if "created_at" not in columns:
|
||||
connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
|
||||
connection.execute(
|
||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
(login, operation_id, now),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_operations WHERE login = ? AND created_at < ?",
|
||||
(login, now - self.operation_retention_seconds),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM today_operations WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.operation_limit),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
@@ -123,10 +162,7 @@ class TodayStore:
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {"revision": revision, "ids": ids}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
@@ -163,6 +199,16 @@ class TodayStore:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
base_revision = operation.get("base_revision")
|
||||
if base_revision is None:
|
||||
base_revision = snapshot["revision"]
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
if base_revision < snapshot["revision"]:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
@@ -185,10 +231,7 @@ class TodayStore:
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
revision += 1 if changed else 0
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
|
||||
+21
-8
@@ -1,12 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
FRONTEND_BUILD = build_frontend(DASHBOARD_FILE.parent)
|
||||
|
||||
LOGIN_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -26,7 +28,7 @@ class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
||||
@router.get("/", response_class=RevalidatingHTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return DASHBOARD_FILE.read_text()
|
||||
return FRONTEND_BUILD.dashboard_html
|
||||
|
||||
|
||||
@router.get("/login", response_class=RevalidatingHTMLResponse)
|
||||
@@ -39,10 +41,21 @@ async def web_app_manifest() -> FileResponse:
|
||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||
|
||||
|
||||
@router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
SERVICE_WORKER_FILE,
|
||||
@router.get("/service-worker.js")
|
||||
async def service_worker() -> Response:
|
||||
return Response(
|
||||
FRONTEND_BUILD.service_worker_source,
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runtime-{digest}.js")
|
||||
async def runtime_bundle(digest: str) -> Response:
|
||||
if digest != FRONTEND_BUILD.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Runtime revision not found")
|
||||
return Response(
|
||||
FRONTEND_BUILD.runtime_bytes,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
@@ -1,9 +1,17 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from src.views import dashboard as dashboard_html
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT_MANIFEST = "\n".join(
|
||||
re.findall(
|
||||
r'^<script src="static/[^\"]+\.js"></script>$',
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
re.MULTILINE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def dashboard_bundle_text() -> str:
|
||||
@@ -18,11 +26,12 @@ def dashboard_bundle_text() -> str:
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# Exercise the real view lookup before adding its separately served assets.
|
||||
# Exercise the real view lookup before adding its separately served bootstrap assets.
|
||||
html = await dashboard_html()
|
||||
return "\n".join(
|
||||
(
|
||||
html,
|
||||
PAGE_SCRIPT_MANIFEST,
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
|
||||
@@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{
|
||||
id:'capture-start',operationId:'capture-start',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Resume',body:'',labelIds:[],
|
||||
completionIntent:'create-and-start',
|
||||
}};
|
||||
let queued = item;
|
||||
const state = {{completed:[]}};
|
||||
const store = {{
|
||||
claimNext:async () => queued ? (queued=null,{{...item}}) : null,
|
||||
complete:async (id, deliveredIssue) => state.completed.push({{id,deliveredIssue}}),
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async url => url === 'api/v1/background-identity' ? {{login:'timmy'}} :
|
||||
{{repository:'stackchain/dashboard',number:389,title:'Resume'}};
|
||||
(async()=>{{
|
||||
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["completed"] == [{
|
||||
"id": "capture-start",
|
||||
"deliveredIssue": {
|
||||
"repository": "stackchain/dashboard", "number": 389, "title": "Resume"
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -116,6 +116,23 @@ const poller = createContextPoller({{
|
||||
}
|
||||
|
||||
|
||||
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const query = createContextPoller.buildRevisionQuery({{
|
||||
context: '0123456789abcdef.12',
|
||||
events: 'fedcba9876543210.3',
|
||||
notifications: 'not-a-token',
|
||||
extra: '0123456789abcdef.1',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{ query }}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"query": "context_revision=0123456789abcdef.12&events_revision=fedcba9876543210.3"
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CREATE_AND_START = Path(__file__).parents[1] / "frontend" / "create-and-start.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const result = flow.complete(issue);
|
||||
flow.complete(issue);
|
||||
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:387:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
"start:387",
|
||||
"announce:Created, added to Today, and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert '<button id="create-and-start-issue" type="submit">Create & start</button>' in html
|
||||
assert '<script src="static/create-and-start.js"></script>' in html
|
||||
assert "const createAndStart = createCreateAndStart({" in dashboard
|
||||
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
|
||||
assert "createAndStart.complete(created)" in dashboard
|
||||
assert ".create-issue-actions" in css and "grid-template-columns" in css
|
||||
assert "BASE + 'static/create-and-start.js'" in worker
|
||||
|
||||
|
||||
def test_create_and_start_intent_is_durable_and_has_a_one_tap_relaunch_continuation():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert "completionIntent: 'create-and-start'" in dashboard
|
||||
assert "result.completions || []" in dashboard
|
||||
assert "(result.completions || []).forEach(completion =>" in dashboard
|
||||
assert "issueOutbox.pendingCompletions(activeFlushLogin)" in dashboard
|
||||
assert "issueOutbox.completeIntent(completion.id, activeFlushLogin)" in dashboard
|
||||
assert 'class="draft-continue"' in dashboard
|
||||
assert "Continue created work" in dashboard
|
||||
|
||||
|
||||
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
|
||||
script = f"""
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:388}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:()=> 'issue:stackchain/dashboard:388:', add:()=> 'added'}},
|
||||
todaySync: {{enqueue:()=>false, flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_can_resume_after_today_sync_admission_recovers():
|
||||
script = f"""
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls=[];let admitted=false;let added=false;
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:389}};
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:stackchain/dashboard:389:',add:()=>{{
|
||||
const outcome=added?'exists':'added';added=true;return outcome;
|
||||
}}}},
|
||||
todaySync:{{enqueue:()=>admitted,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
const first=flow.complete(issue);
|
||||
admitted=true;
|
||||
const second=flow.complete(issue);
|
||||
process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == "sync-unavailable"
|
||||
assert output["second"] == "started"
|
||||
assert output["calls"][-5:] == [
|
||||
"refresh", "flush", "warm", "start",
|
||||
"Created, added to Today, and ready to work.",
|
||||
]
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
from src.views import FRONTEND_BUILD
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -956,9 +957,16 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
|
||||
login = await client.get("/login")
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
static = await client.get("/static/session.js")
|
||||
runtime = await client.get("/" + FRONTEND_BUILD.runtime_name)
|
||||
|
||||
assert signed_in.status_code == 200
|
||||
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
|
||||
assert [login.status_code, manifest.status_code, static.status_code, runtime.status_code] == [
|
||||
200,
|
||||
200,
|
||||
200,
|
||||
200,
|
||||
]
|
||||
assert runtime.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -121,6 +121,26 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||
id:'continue-1',operationId:'continue-1',repository:'stackchain/dashboard',
|
||||
title:'Resume this issue',body:'',ownerLogin:'timmy',status:'completion',queuedAt:200,
|
||||
completionIntent:'create-and-start',deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume this issue'}}
|
||||
}}]}})]]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null}};
|
||||
const item = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["status"] == "completion"
|
||||
assert output["label"] == "Created · ready to start"
|
||||
assert output["continuation"] is True
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
|
||||
|
||||
|
||||
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
second = build_frontend(FRONTEND)
|
||||
|
||||
assert first.runtime_name == second.runtime_name
|
||||
assert first.runtime_bytes == second.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
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
assert changed.runtime_name != first.runtime_name
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
|
||||
for source in build.page_sources:
|
||||
if source == "static/background-issue-sync.js":
|
||||
continue
|
||||
assert f"BASE + '{source}'" not in build.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
html = await dashboard()
|
||||
bundle = await runtime_bundle(build.runtime_digest)
|
||||
worker = await service_worker()
|
||||
|
||||
assert build.runtime_name in html
|
||||
assert bundle.body == build.runtime_bytes
|
||||
assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert worker.headers["cache-control"] == "no-cache"
|
||||
assert build.runtime_name.encode() in worker.body
|
||||
@@ -68,7 +68,57 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||
]
|
||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
||||
assert all(item["status"] == "queued" for item in output["items"])
|
||||
assert output["stored"]["version"] == 2
|
||||
assert output["stored"]["version"] == 3
|
||||
|
||||
|
||||
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1',
|
||||
fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}),
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start'
|
||||
}});
|
||||
const reloaded = createIssueOutbox({{
|
||||
storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}})
|
||||
}});
|
||||
reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')),
|
||||
result,
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["persisted"]["version"] == 3
|
||||
completion = output["result"]["completions"][0]
|
||||
assert completion["intent"] == "create-and-start"
|
||||
assert completion["ownerLogin"] == "timmy"
|
||||
assert completion["operationId"] == "create-start-1"
|
||||
assert completion["issue"]["number"] == 389
|
||||
|
||||
|
||||
def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery():
|
||||
script = f"""
|
||||
const createIssueOutbox = 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 outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence)
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}});
|
||||
const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}});
|
||||
const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}});
|
||||
process.stdout.write(JSON.stringify({{started,ordinary}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["started"]["completionIntent"] == "create-and-start"
|
||||
assert "completionIntent" not in output["ordinary"]
|
||||
assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-1"
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
@@ -384,13 +434,45 @@ process.stdout.write(JSON.stringify(outbox.list()));
|
||||
]
|
||||
|
||||
|
||||
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{
|
||||
id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start'
|
||||
}}]}}));
|
||||
const outbox=createIssueOutbox({{storage}});
|
||||
outbox.reconcileBackground([{{
|
||||
id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start',
|
||||
deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}}
|
||||
}}]);
|
||||
const blocked=outbox.pendingCompletions('alexander');
|
||||
const pending=outbox.pendingCompletions('timmy');
|
||||
const retained=outbox.list();
|
||||
outbox.completeIntent('resume','timmy');
|
||||
process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["blocked"] == []
|
||||
assert output["pending"] == [{
|
||||
"id": "resume", "intent": "create-and-start", "ownerLogin": "timmy",
|
||||
"operationId": "resume",
|
||||
"issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"},
|
||||
}]
|
||||
assert output["retained"][0]["status"] == "completion"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
|
||||
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "throw new Error('Background Sync unavailable')" in html
|
||||
|
||||
@@ -86,6 +86,114 @@ def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
assert replay["duplicate_operation_ids"] == ["first", "second"]
|
||||
|
||||
|
||||
def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
first = store.apply_batch("timmy", [{
|
||||
"operation_id": "newer-device",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-12T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
|
||||
replay = store.apply_batch("timmy", [
|
||||
{
|
||||
"operation_id": "stale-device",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
},
|
||||
{
|
||||
"operation_id": "unrelated-item",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-11T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
},
|
||||
])
|
||||
|
||||
assert first["revision"] == 1
|
||||
assert replay == {
|
||||
"revision": 2,
|
||||
"records": {
|
||||
"issue:r:1:": "2026-08-12T09:00:00.000Z",
|
||||
"issue:r:2:": "2026-08-11T09:00:00.000Z",
|
||||
},
|
||||
"accepted_operation_ids": ["unrelated-item"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [
|
||||
{"operation_id": "stale-device", "reason": "stale_intent"}
|
||||
],
|
||||
}
|
||||
duplicate = store.apply_batch("timmy", [{
|
||||
"operation_id": "stale-device",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"base_revision": 2,
|
||||
}])
|
||||
assert duplicate["duplicate_operation_ids"] == ["stale-device"]
|
||||
assert duplicate["revision"] == 2
|
||||
assert duplicate["records"] == replay["records"]
|
||||
|
||||
|
||||
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
|
||||
for index in range(4):
|
||||
store.apply(
|
||||
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
store.apply(
|
||||
"alexander", "other", "defer", "issue:r:99:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
|
||||
store.apply("timmy", "old", "restore", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "restore", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "restore", "issue:r:3:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
@@ -129,6 +237,21 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
stale = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operations": [{
|
||||
"operation_id": "offline-stale",
|
||||
"action": "restore",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"base_revision": 0,
|
||||
}],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/later")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
@@ -143,6 +266,10 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert stale.json()["rejected_operations"] == [
|
||||
{"operation_id": "offline-stale", "reason": "stale_intent"}
|
||||
]
|
||||
assert stale.json()["revision"] == 2
|
||||
assert fetched.json() == {"revision": 2, "records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
|
||||
@@ -90,12 +90,67 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
}]}
|
||||
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const statuses=[];const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];const statuses=[];const adopted=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'offline-old',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
if(!options.method)return {{revision:3,records:{{'issue:r:1:':'2026-08-12T09:00:00.000Z'}}}};
|
||||
const body=JSON.parse(options.body);requests.push(body);
|
||||
return {{revision:4,records:{{'issue:r:1:':'2026-08-13T09:00:00.000Z'}},accepted_operation_ids:[],duplicate_operation_ids:[],rejected_operations:[{{operation_id:'offline-old',reason:'stale_intent'}}]}};
|
||||
}},onRemoteRecords:records=>adopted.push(records),onStatus:(state,detail={{}})=>statuses.push([state,detail]),
|
||||
}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{requests,statuses,adopted,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "offline-old",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 3,
|
||||
}]}]
|
||||
assert result["statuses"][-1] == ["conflict", {"count": 1}]
|
||||
assert result["adopted"][-1] == {
|
||||
"issue:r:1:": "2026-08-13T09:00:00.000Z"
|
||||
}
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
@@ -136,6 +191,7 @@ sync.enqueue('restore','issue:r:2:');
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": None,
|
||||
"base_revision": 0,
|
||||
}
|
||||
],
|
||||
"status": "retrying",
|
||||
@@ -283,10 +339,13 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
assert "Later saved to account." in html
|
||||
assert "Another device changed this Later item" in html
|
||||
assert "Today edit expired after 30 days offline" in html
|
||||
assert "Later edit expired after 30 days offline" in html
|
||||
|
||||
|
||||
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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
@@ -90,9 +91,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
assert result["revisions"] == {
|
||||
"context": 1, "events": 1, "notifications": 1
|
||||
}
|
||||
assert set(result["revisions"]) == {"context", "events", "notifications"}
|
||||
assert len(set(result["revisions"].values())) == 1
|
||||
assert result["revisions"]["context"].endswith(".1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -129,6 +130,56 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
monkeypatch.setattr(main, "_live_revision_generation", "worker-a")
|
||||
|
||||
worker_a = payload(await main.live_snapshot())
|
||||
worker_a_token = worker_a["revisions"]["context"]
|
||||
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
monkeypatch.setattr(main, "_live_revision_generation", "worker-b")
|
||||
|
||||
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
|
||||
|
||||
assert worker_a_token == "worker-a.1"
|
||||
assert worker_b["revisions"]["context"] == "worker-b.1"
|
||||
assert worker_b["context"]["user"]["login"] == "timmy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_rejects_malformed_revision_before_upstream_work(monkeypatch):
|
||||
async def unexpected_user():
|
||||
raise AssertionError("malformed revision reached upstream work")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unexpected_user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/api/v1/live?context_revision=not-a-token")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch):
|
||||
async def user():
|
||||
|
||||
@@ -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-v54" in worker
|
||||
assert "stackchain-dashboard-shell-v60" in worker
|
||||
@@ -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-v54" in worker
|
||||
assert "stackchain-dashboard-shell-v60" in worker
|
||||
+291
-1
@@ -20,6 +20,87 @@ CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
|
||||
|
||||
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
||||
script = f"""
|
||||
const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
|
||||
function disclosure(name) {{
|
||||
const listeners = {{}};
|
||||
const attrs = {{}};
|
||||
const summary = {{
|
||||
focusCount: 0,
|
||||
setAttribute(key, value) {{ attrs[key] = value; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
return {{
|
||||
name, open: false, dataset: {{}}, summary, attrs,
|
||||
querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
|
||||
addEventListener(type, listener) {{ listeners[type] = listener; }},
|
||||
fire(type, event = {{}}) {{ listeners[type](event); }},
|
||||
}};
|
||||
}}
|
||||
const first = disclosure('first');
|
||||
const second = disclosure('second');
|
||||
const controller = createCardPlanning({{
|
||||
querySelectorAll() {{ return [first, second]; }},
|
||||
}});
|
||||
controller.wire();
|
||||
first.open = true;
|
||||
first.fire('toggle');
|
||||
second.open = true;
|
||||
second.fire('toggle');
|
||||
const escape = {{
|
||||
key: 'Escape', prevented: 0, stopped: 0,
|
||||
preventDefault() {{ this.prevented += 1; }},
|
||||
stopPropagation() {{ this.stopped += 1; }},
|
||||
}};
|
||||
second.fire('keydown', escape);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
firstOpen: first.open,
|
||||
firstExpanded: first.attrs['aria-expanded'],
|
||||
secondOpen: second.open,
|
||||
secondExpanded: second.attrs['aria-expanded'],
|
||||
secondFocus: second.summary.focusCount,
|
||||
prevented: escape.prevented,
|
||||
stopped: escape.stopped,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"firstOpen": False,
|
||||
"firstExpanded": "false",
|
||||
"secondOpen": False,
|
||||
"secondExpanded": "false",
|
||||
"secondFocus": 1,
|
||||
"prevented": 1,
|
||||
"stopped": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_work_cards_progressively_disclose_planning_on_phones():
|
||||
html = await dashboard()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/card-planning.js"></script>' in html
|
||||
assert "const cardPlanning = createCardPlanning(document);" in html
|
||||
assert "cardPlanning.wire();" in html
|
||||
assert (
|
||||
'<details class="card-planning" data-card-planning>'
|
||||
'<summary aria-expanded="false">Plan or defer</summary>'
|
||||
'<div class="card-planning-actions">'
|
||||
) in html
|
||||
assert '.card-planning > summary { display:none;' in html
|
||||
assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
|
||||
mobile = html.index('@media (max-width: 600px)')
|
||||
assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
|
||||
assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
|
||||
assert "BASE + 'static/card-planning.js'" in service_worker
|
||||
|
||||
|
||||
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
|
||||
@@ -1347,6 +1428,157 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_resumes_current_item_after_reload():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const items = [1, 2, 3].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const first = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: firstCheckpoint,
|
||||
onOpen: item => opened.push('first:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
first.start();
|
||||
first.next();
|
||||
|
||||
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const resumed = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: secondCheckpoint,
|
||||
onOpen: item => opened.push('resumed:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
const available = resumed.resumable();
|
||||
const didResume = resumed.resume();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"available": True,
|
||||
"didResume": True,
|
||||
"opened": ["first:1", "first:2", "resumed:2"],
|
||||
"checkpoint": {
|
||||
"version": 1,
|
||||
"login": "timmy",
|
||||
"identity": "issue:stackchain/dashboard:2:",
|
||||
"index": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let login = 'timmy';
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
|
||||
getFilter: () => 'all', checkpoint,
|
||||
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
login = 'alexander';
|
||||
const visibleToOtherAccount = session.resumable();
|
||||
const retainedForOwner = values.size;
|
||||
login = 'timmy';
|
||||
session.end();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"visibleToOtherAccount": False,
|
||||
"retainedForOwner": 1,
|
||||
"active": False,
|
||||
"stored": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
|
||||
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
|
||||
}})]]);
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [], getFilter:() => 'all', checkpoint,
|
||||
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
const resumed = session.resume();
|
||||
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
|
||||
|
||||
|
||||
def test_today_session_keeps_working_when_checkpoint_storage_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let errors = 0;
|
||||
const opened = [];
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage:{{
|
||||
getItem:() => null,
|
||||
setItem:() => {{ throw new Error('quota'); }},
|
||||
removeItem:() => {{ throw new Error('quota'); }},
|
||||
}},
|
||||
getLogin:() => 'timmy',
|
||||
onError:() => {{ errors += 1; }},
|
||||
}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
|
||||
getFilter:() => 'all', checkpoint,
|
||||
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
||||
html = await dashboard()
|
||||
@@ -1361,6 +1593,23 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
assert 'aria-live="polite" data-work-session-progress' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="resume-today-session"' in html
|
||||
assert 'id="end-today-session"' in html
|
||||
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'checkpoint: sessionCheckpoint' in html
|
||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume()" in html
|
||||
assert "workSession.end()" in html
|
||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
||||
html = await dashboard()
|
||||
@@ -1376,6 +1625,47 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
|
||||
assert opener in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
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
|
||||
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
|
||||
assert "if (workSession.active()) workSession.complete();" in close_handler
|
||||
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
|
||||
|
||||
def test_work_session_can_start_at_a_newly_created_item():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}}));
|
||||
const opened = [];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items,
|
||||
getFilter: () => 'all',
|
||||
getMilestone: () => '',
|
||||
onOpen: item => opened.push(item.number),
|
||||
onProgress: () => {{}},
|
||||
onFinish: () => {{}},
|
||||
}});
|
||||
const started = session.start(items[2]);
|
||||
process.stdout.write(JSON.stringify({{started, opened}}));
|
||||
"""
|
||||
|
||||
output = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
assert json.loads(output) == {"started": True, "opened": [3]}
|
||||
|
||||
|
||||
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
|
||||
items = [
|
||||
{"kind": "issue", "is_review": False},
|
||||
@@ -3267,7 +3557,7 @@ async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
||||
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
||||
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
||||
"issueOutbox.enqueueDurably(captureDraft)"
|
||||
"issueOutbox.enqueueDurably(durableDraft)"
|
||||
)
|
||||
assert ".create-issue-duplicate-card" in html
|
||||
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_plan_today_drafts_capacity_order_and_cancel_without_writing():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const saved = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
limit: 3,
|
||||
save: ids => saved.push(ids),
|
||||
}});
|
||||
planner.open([item(1), item(2)], [item(1), item(2), item(3), item(4)]);
|
||||
const add = planner.toggle(item(3));
|
||||
const full = planner.toggle(item(4));
|
||||
const moved = planner.move('issue:stackchain/dashboard:3:', 'up');
|
||||
const snapshot = planner.snapshot();
|
||||
planner.cancel();
|
||||
process.stdout.write(JSON.stringify({{add, full, moved, snapshot, afterCancel:planner.snapshot(), saved}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"add": "added",
|
||||
"full": "full",
|
||||
"moved": True,
|
||||
"snapshot": {
|
||||
"open": True,
|
||||
"ids": [
|
||||
"issue:stackchain/dashboard:1:",
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
"count": 3,
|
||||
"limit": 3,
|
||||
},
|
||||
"afterCancel": {"open": False, "ids": [], "count": 0, "limit": 3},
|
||||
"saved": [],
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_saves_exact_draft_and_starts_first_item_only_after_success():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const calls = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: ids => {{ calls.push(['save', ...ids]); return true; }},
|
||||
start: first => calls.push(['start', first.number]),
|
||||
}});
|
||||
planner.open([item(1)], [item(1), item(2)]);
|
||||
planner.toggle(item(2));
|
||||
const result = planner.commit({{start:true}});
|
||||
process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "saved",
|
||||
"calls": [
|
||||
["save", "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
["start", 1],
|
||||
],
|
||||
"snapshot": {"open": False, "ids": [], "count": 0, "limit": 5},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/plan-today.js"></script>' in html
|
||||
assert 'id="plan-today"' in html
|
||||
assert 'id="plan-today-sheet" role="dialog"' in html
|
||||
assert 'id="plan-today-capacity"' in html
|
||||
assert 'id="save-and-start-today"' in html
|
||||
assert "const planToday = createPlanToday({" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
assert "todaySync.enqueue('add'" in html
|
||||
assert "qs('#plan-today').addEventListener('click'" in html
|
||||
assert ".plan-today-actions { position:sticky; bottom:0;" in html
|
||||
assert ".plan-today-actions button { min-height:44px;" in html
|
||||
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
|
||||
assert ".plan-today-item { grid-template-columns:1fr; }" in html
|
||||
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
||||
|
||||
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
@@ -57,3 +57,12 @@ def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||
assert "previously opened unread update" in text
|
||||
assert "replies enter the account-bound durable outbox" in text
|
||||
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
||||
|
||||
|
||||
def test_readme_documents_planning_sync_retention_contract():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "30 days" in text
|
||||
assert "4,096 operation receipts per account" in text
|
||||
assert "expired edit" in text
|
||||
assert "account plan is kept" in text
|
||||
@@ -105,10 +105,19 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v60" 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_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -116,14 +125,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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" 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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -132,21 +141,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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" 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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" 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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -340,7 +349,9 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/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/plan-today.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
@@ -351,6 +362,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
|
||||
@@ -23,7 +23,7 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
|
||||
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "onSnapshot: renderLiveSnapshot" in html
|
||||
assert "section + '_revision'" in html
|
||||
assert "createContextPoller.buildRevisionQuery(revisions)" in html
|
||||
assert "workChanged = contextChanged || notificationsChanged" in html
|
||||
assert "renderContextSnapshot(snapshot.context)" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
|
||||
@@ -107,6 +107,96 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
|
||||
assert replay["duplicate_operation_ids"] == ["two", "three"]
|
||||
|
||||
|
||||
def test_operation_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE today_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = TodayStore(path, limit=20, operation_limit=3, clock=lambda: 1_000)
|
||||
for index in range(5):
|
||||
store.apply("timmy", f"op-{index}", "add", f"issue:r:{index}:")
|
||||
store.apply("alexander", "other", "add", "issue:r:99:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",), ("op-4",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_pruned_stale_today_operation_cannot_reorder_a_newer_plan(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=5, operation_limit=1)
|
||||
store.apply_batch("timmy", [
|
||||
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:", "base_revision": 0},
|
||||
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:", "base_revision": 0},
|
||||
])
|
||||
store.apply_batch("timmy", [{
|
||||
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 2,
|
||||
}])
|
||||
newer = store.apply_batch("timmy", [{
|
||||
"operation_id": "newer-move", "action": "move", "item_id": "issue:r:1:",
|
||||
"direction": "up", "base_revision": 3,
|
||||
}])
|
||||
|
||||
replay = store.apply_batch("timmy", [{
|
||||
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 2,
|
||||
}])
|
||||
|
||||
assert replay["ids"] == newer["ids"] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert replay["revision"] == 4
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
assert replay["rejected_operations"] == [
|
||||
{"operation_id": "offline-move", "reason": "stale_intent"}
|
||||
]
|
||||
|
||||
|
||||
def test_today_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(
|
||||
path, limit=5, operation_retention_seconds=60, clock=lambda: clock[0]
|
||||
)
|
||||
store.apply("timmy", "old", "add", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "add", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "remove", "issue:r:1:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
def test_today_api_model_preserves_client_base_revision():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="offline", action="move", item_id="issue:r:2:",
|
||||
direction="up", base_revision=7,
|
||||
)
|
||||
|
||||
assert operation.model_dump()["base_revision"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
|
||||
@@ -74,8 +74,8 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
assert result == {
|
||||
"sharesInflight": True,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
@@ -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-v54" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
@@ -137,12 +137,41 @@ sync.enqueue('add', 'issue:r:2:');
|
||||
"action": "add",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": None,
|
||||
"base_revision": 0,
|
||||
}]}
|
||||
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_operations_capture_server_revision_and_expire_before_replay():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); const statuses=[]; const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-op',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:7,ids:['server']}}}},
|
||||
onRemoteIds:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('move','issue:r:2:','up');
|
||||
const pendingBefore=sync.pending();
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{pendingBefore,requests,statuses,pendingAfter:sync.pending()}}));}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
|
||||
assert result["pendingBefore"] == [{
|
||||
"operation_id": "old-op", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 7,
|
||||
}]
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pendingAfter"] == []
|
||||
|
||||
|
||||
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
@@ -184,6 +213,7 @@ sync.enqueue('move', 'issue:r:2:', 'up');
|
||||
"action": "move",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": "up",
|
||||
"base_revision": 0,
|
||||
}],
|
||||
"status": "retrying",
|
||||
}
|
||||
@@ -221,7 +251,7 @@ sync.enqueue('add','issue:r:2:');
|
||||
assert result["scheduled"] == [2000]
|
||||
assert result["attempts"] == 2
|
||||
assert result["patches"] == [{
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0,
|
||||
}]
|
||||
assert result["pending"] == []
|
||||
assert ["retrying", 2000] in result["statuses"]
|
||||
@@ -254,6 +284,7 @@ process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(),
|
||||
"action": "remove",
|
||||
"item_id": "issue:r:1:",
|
||||
"direction": None,
|
||||
"base_revision": 0,
|
||||
}],
|
||||
"statuses": ["pending", "pending"],
|
||||
}
|
||||
@@ -280,8 +311,8 @@ process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||
"first": True,
|
||||
"second": False,
|
||||
"pending": [
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -346,8 +377,8 @@ sync.enqueue('remove', '5');
|
||||
assert result == {
|
||||
"result": False,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None},
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None, "base_revision": 0},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["1", "2", "3", "4"],
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from src.views import FRONTEND_BUILD, dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -28,7 +28,7 @@ async def test_dashboard_bootstrap_uses_csp_compatible_static_assets():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<link rel="stylesheet" href="static/dashboard.css" />' in html
|
||||
assert '<script src="static/dashboard.js"></script>' in html
|
||||
assert f'<script src="{FRONTEND_BUILD.runtime_name}"></script>' in html
|
||||
assert "<style>" not in html
|
||||
assert "<script>" not in html
|
||||
|
||||
@@ -42,7 +42,7 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert 'src="static/search-preview.js"' in html
|
||||
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
|
||||
assert ".search-preview-panel" in css
|
||||
assert "height:100dvh" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
Reference in new issue
Block a user