Compare commits
35 Commits
timmy/1460
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9d602f85f | |||
| 2a06cf1daa | |||
| b0dbcb5a25 | |||
| 2766a75f24 | |||
| 5f9b5b3a7f | |||
| cec5b93665 | |||
| 5af34508b4 | |||
| 9b1abf37d0 | |||
| b0b28a936c | |||
| f23be99ab4 | |||
| 1484c579f9 | |||
| afec9b11d1 | |||
| ca37d8a1bb | |||
| 94bd2cafba | |||
| 2b630d3fa8 | |||
| 19732ea5a6 | |||
| 12a7a3c4b0 | |||
| 454e2dcffc | |||
| 0e70fb9b62 | |||
| eb101bf5ce | |||
| 46e06c1a37 | |||
| 02b9fdd2e4 | |||
| 5ccf499b1a | |||
| e8b2f31e5e | |||
| c1d719842f | |||
| 4a99f54f12 | |||
| 8909bedd78 | |||
| 5d9cec1dd7 | |||
| 2a44cf20fc | |||
| 1e8d27c18f | |||
| c5b801460d | |||
| b18aa5ed63 | |||
| dbc305108a | |||
| 3b9f184a47 | |||
| bf716cab6e |
23
README.md
23
README.md
|
|
@ -121,6 +121,27 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
|
||||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||||
`.stackchain-state/saved-searches.sqlite3` path.
|
`.stackchain-state/saved-searches.sqlite3` path.
|
||||||
|
|
||||||
|
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
|
||||||
|
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
|
||||||
|
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
|
||||||
|
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
|
||||||
|
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
|
||||||
|
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
|
||||||
|
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
|
||||||
|
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
|
||||||
|
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
|
||||||
|
|
||||||
|
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
|
||||||
|
review, Filed item, or update records its canonical detail route locally before navigation and marks
|
||||||
|
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
|
||||||
|
merge the server list without duplicate routes, while each confirmed account remains bounded to its
|
||||||
|
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
|
||||||
|
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
|
||||||
|
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
|
||||||
|
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
|
||||||
|
private-state key; stale responses from a prior account are discarded. Set
|
||||||
|
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
|
||||||
|
|
||||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||||
|
|
@ -300,7 +321,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
||||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
|
||||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ function createContextPoller({
|
||||||
|
|
||||||
let request;
|
let request;
|
||||||
try {
|
try {
|
||||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request = Promise.reject(error);
|
request = Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -888,8 +888,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
.issue-sheet-header button { min-height:44px; }
|
.issue-sheet-header button { min-height:44px; }
|
||||||
|
.current-work-pin { display:none; }
|
||||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
|
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
|
||||||
@media (max-width:600px) {
|
@media (max-width:600px) {
|
||||||
|
[data-current-work-pin] { display:inline-flex; align-items:center; justify-content:center; min-width:64px; min-height:44px; padding-inline:12px; }
|
||||||
|
[data-current-work-pin="unpin"] { border-color:#60a5fa; background:#17365a; color:#fff; }
|
||||||
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
|
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
|
||||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||||
|
|
@ -1525,6 +1528,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
|
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
|
||||||
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
|
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
|
||||||
.mobile-queue-priority-footer button { min-height:44px; }
|
.mobile-queue-priority-footer button { min-height:44px; }
|
||||||
|
.mobile-queue-priority-conflict { margin:0 8px 10px; padding:10px; border:1px solid #f59e0b; border-radius:10px; background:#3b2808; }
|
||||||
|
.mobile-queue-priority-conflict p { margin:0 0 8px; }
|
||||||
|
.mobile-queue-priority-conflict button { min-height:44px; margin:4px 4px 0 0; }
|
||||||
.mobile-queue-group { margin-top:16px; }
|
.mobile-queue-group { margin-top:16px; }
|
||||||
.mobile-queue-group h3 { font-size:1rem; }
|
.mobile-queue-group h3 { font-size:1rem; }
|
||||||
.mobile-queue-all { margin-top:16px; }
|
.mobile-queue-all { margin-top:16px; }
|
||||||
|
|
@ -1548,6 +1554,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||||
.mobile-queue-list small { color:var(--muted); }
|
.mobile-queue-list small { color:var(--muted); }
|
||||||
|
.mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
|
||||||
|
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
|
||||||
|
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
|
||||||
|
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
|
||||||
|
.mobile-pinned-work-toggle { min-height:44px; width:100%; margin-top:8px; }
|
||||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@
|
||||||
let offlineWorkMode = false;
|
let offlineWorkMode = false;
|
||||||
let renderMobileQueuePresentation = () => {};
|
let renderMobileQueuePresentation = () => {};
|
||||||
let mobileQueuePriority = null;
|
let mobileQueuePriority = null;
|
||||||
|
let mobileRecentWork = { record:() => false, render:() => 0 };
|
||||||
const followingQueue = attachFollowing(item => {
|
const followingQueue = attachFollowing(item => {
|
||||||
searchPreviewReturnKind = 'following';
|
searchPreviewReturnKind = 'following';
|
||||||
return searchPreview.open(item);
|
return searchPreview.open(item);
|
||||||
|
|
@ -313,7 +314,7 @@
|
||||||
find: () => qs('#find-work').click(),
|
find: () => qs('#find-work').click(),
|
||||||
new: () => qs('#new-issue').click(),
|
new: () => qs('#new-issue').click(),
|
||||||
search: () => qs('#open-palette').click(),
|
search: () => qs('#open-palette').click(),
|
||||||
queues: () => refreshTomorrowQueueSummary(),
|
queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
|
||||||
},
|
},
|
||||||
observe(callback, overlays) {
|
observe(callback, overlays) {
|
||||||
const observer = new MutationObserver(callback);
|
const observer = new MutationObserver(callback);
|
||||||
|
|
@ -358,6 +359,7 @@
|
||||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||||
let liveMode = true;
|
let liveMode = true;
|
||||||
|
let initialAccountRecovery = Promise.resolve(false);
|
||||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||||
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
|
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
|
||||||
|
|
@ -430,20 +432,47 @@
|
||||||
let planningOwnerLogin = '';
|
let planningOwnerLogin = '';
|
||||||
let planningOwnerAccountKey = '';
|
let planningOwnerAccountKey = '';
|
||||||
let activeFlushLogin = '';
|
let activeFlushLogin = '';
|
||||||
|
mobileRecentWork = createMobileRecentWork({
|
||||||
|
storage:localStorage,
|
||||||
|
getLogin:() => confirmedOwnerLogin,
|
||||||
|
fetchJson:fetchReviewJson,
|
||||||
|
document,
|
||||||
|
section:qs('#mobile-recent-work'),
|
||||||
|
list:qs('#mobile-recent-work-list'),
|
||||||
|
pinnedSection:qs('#mobile-pinned-work'),
|
||||||
|
pinnedList:qs('#mobile-pinned-work-list'),
|
||||||
|
pinnedToggle:qs('#mobile-pinned-work-toggle'),
|
||||||
|
detailPins:qsa('[data-current-work-pin]'),
|
||||||
|
status:qs('#mobile-recent-work-status'),
|
||||||
|
openRoute:fragment => {
|
||||||
|
const sheet = qs('#mobile-queue-sheet');
|
||||||
|
if (sheet.open) sheet.close();
|
||||||
|
if (window.location.hash !== fragment) window.history.pushState({ workRoute:fragment }, '', fragment);
|
||||||
|
workRoute.sync();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mobileRecentWork.startLifecycle({window, document});
|
||||||
mobileQueuePriority = createMobileQueuePriority({
|
mobileQueuePriority = createMobileQueuePriority({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => confirmedOwnerLogin,
|
getLogin: () => confirmedOwnerLogin,
|
||||||
|
fetchJson: fetchReviewJson,
|
||||||
document,
|
document,
|
||||||
list: qs('#mobile-queue-priority-list'),
|
list: qs('#mobile-queue-priority-list'),
|
||||||
resetButton: qs('#reset-mobile-queue-priority'),
|
resetButton: qs('#reset-mobile-queue-priority'),
|
||||||
status: qs('#mobile-queue-priority-status'),
|
status: qs('#mobile-queue-priority-status'),
|
||||||
|
conflict: qs('#mobile-queue-priority-conflict'),
|
||||||
|
keepLocalButton: qs('#keep-local-mobile-queue-priority'),
|
||||||
|
useRemoteButton: qs('#use-remote-mobile-queue-priority'),
|
||||||
labels: {
|
labels: {
|
||||||
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
|
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
|
||||||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||||||
},
|
},
|
||||||
onChange: () => renderMobileQueuePresentation(),
|
onChange: () => {
|
||||||
|
renderMobileQueuePresentation();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mobileQueuePriority.start();
|
mobileQueuePriority.start();
|
||||||
|
mobileQueuePriority.startLifecycle({window, document});
|
||||||
let rR = null;
|
let rR = null;
|
||||||
function rRC() {
|
function rRC() {
|
||||||
if (rR) return rR;
|
if (rR) return rR;
|
||||||
|
|
@ -676,6 +705,7 @@
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
|
error.payload = payload;
|
||||||
|
|
||||||
error.code = payload.detail?.code;
|
error.code = payload.detail?.code;
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
|
|
@ -2052,6 +2082,8 @@
|
||||||
qs('#my-work-action-status').textContent = '';
|
qs('#my-work-action-status').textContent = '';
|
||||||
closeOpenWorkSheets();
|
closeOpenWorkSheets();
|
||||||
await openRoutedWorkSection(item);
|
await openRoutedWorkSection(item);
|
||||||
|
mobileRecentWork.record(item);
|
||||||
|
mobileRecentWork.setCurrent(item);
|
||||||
const route = createWorkRoute.parse(window.location.hash);
|
const route = createWorkRoute.parse(window.location.hash);
|
||||||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||||||
},
|
},
|
||||||
|
|
@ -2082,6 +2114,7 @@
|
||||||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||||
|
|
||||||
function closeOpenWorkSheets() {
|
function closeOpenWorkSheets() {
|
||||||
|
mobileRecentWork.setCurrent(null);
|
||||||
issueVoiceReply.cancel();
|
issueVoiceReply.cancel();
|
||||||
pullVoiceReply.cancel();
|
pullVoiceReply.cancel();
|
||||||
updateVoiceReply.cancel();
|
updateVoiceReply.cancel();
|
||||||
|
|
@ -5636,8 +5669,13 @@
|
||||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||||
if (activeFlushLogin) {
|
if (activeFlushLogin) {
|
||||||
confirmedOwnerLogin = activeFlushLogin;
|
confirmedOwnerLogin = activeFlushLogin;
|
||||||
|
planningOwnerLogin = activeFlushLogin;
|
||||||
|
planningOwnerAccountKey = snapshot.context.user?.id ?
|
||||||
|
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
||||||
mobileQueuePriority.render();
|
mobileQueuePriority.render();
|
||||||
renderMobileQueuePresentation();
|
renderMobileQueuePresentation();
|
||||||
|
void mobileRecentWork.load();
|
||||||
|
void mobileQueuePriority.load();
|
||||||
void refreshPhotoDraftInbox();
|
void refreshPhotoDraftInbox();
|
||||||
timerView.restore(todaySync.flush());
|
timerView.restore(todaySync.flush());
|
||||||
restoreReleaseReceipt();
|
restoreReleaseReceipt();
|
||||||
|
|
@ -5657,7 +5695,10 @@
|
||||||
if (planningOwnerLogin) {
|
if (planningOwnerLogin) {
|
||||||
syncPendingTomorrow();
|
syncPendingTomorrow();
|
||||||
todaySync.migrate(todayWork.read());
|
todaySync.migrate(todayWork.read());
|
||||||
todaySync.flush();
|
initialAccountRecovery = Promise.all([
|
||||||
|
initialAccountRecovery,
|
||||||
|
todaySync.flush(),
|
||||||
|
]).then(() => true);
|
||||||
laterSync.migrate(laterWork.read());
|
laterSync.migrate(laterWork.read());
|
||||||
laterSync.flush();
|
laterSync.flush();
|
||||||
}
|
}
|
||||||
|
|
@ -8463,7 +8504,8 @@
|
||||||
});
|
});
|
||||||
let pushControllerReady = Promise.resolve(null);
|
let pushControllerReady = Promise.resolve(null);
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
pushControllerReady = (workspaceLifecycle.serviceWorkerReady ||
|
||||||
|
navigator.serviceWorker.register('service-worker.js')).then(async () => {
|
||||||
await issueCaptureFeatures.load('push-notifications');
|
await issueCaptureFeatures.load('push-notifications');
|
||||||
const controller = createPushNotifications({
|
const controller = createPushNotifications({
|
||||||
control:qs('#push-updates'),
|
control:qs('#push-updates'),
|
||||||
|
|
@ -8587,6 +8629,20 @@
|
||||||
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
|
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
|
||||||
}
|
}
|
||||||
if (!adoptedProgressiveSnapshot) await load();
|
if (!adoptedProgressiveSnapshot) await load();
|
||||||
|
else if (!confirmedOwnerLogin) {
|
||||||
|
await contextPoller.refresh({ force:true, full:true });
|
||||||
|
if (!confirmedOwnerLogin) {
|
||||||
|
try {
|
||||||
|
const identity = await fetchReviewJson('api/v1/background-identity');
|
||||||
|
const login = String(identity?.login || '').trim();
|
||||||
|
if (login) {
|
||||||
|
confirmedOwnerLogin = login;
|
||||||
|
planningOwnerLogin = login;
|
||||||
|
initialAccountRecovery = timerView.restore(todaySync.flush());
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (progressiveWorkHandoff?.openWork) {
|
if (progressiveWorkHandoff?.openWork) {
|
||||||
const progressiveItem = lastMyWork.find(item =>
|
const progressiveItem = lastMyWork.find(item =>
|
||||||
item.repository === progressiveWorkHandoff.openWork.repository &&
|
item.repository === progressiveWorkHandoff.openWork.repository &&
|
||||||
|
|
@ -8605,4 +8661,8 @@
|
||||||
|
|
||||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||||
setInterval(widgetTick, 1000);
|
setInterval(widgetTick, 1000);
|
||||||
|
await initialAccountRecovery;
|
||||||
|
if (planningOwnerLogin) await todaySync.flush();
|
||||||
|
await timerView.restore(Promise.resolve(true));
|
||||||
|
workspaceLifecycle.markWorkspaceReady?.();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1053,6 +1053,7 @@
|
||||||
<div class="small" id="issue-sheet-key"></div>
|
<div class="small" id="issue-sheet-key"></div>
|
||||||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
||||||
|
|
@ -1553,6 +1554,7 @@
|
||||||
<div class="small" id="update-sheet-key"></div>
|
<div class="small" id="update-sheet-key"></div>
|
||||||
<h3 id="update-sheet-title">Unread update</h3>
|
<h3 id="update-sheet-title">Unread update</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||||
<nav class="mobile-update-detail-nav" aria-label="Update sections">
|
<nav class="mobile-update-detail-nav" aria-label="Update sections">
|
||||||
|
|
@ -1676,6 +1678,7 @@
|
||||||
<section class="pull-sheet-panel">
|
<section class="pull-sheet-panel">
|
||||||
<div class="pull-sheet-header">
|
<div class="pull-sheet-header">
|
||||||
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
|
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
|
||||||
|
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||||
<button id="close-pull-sheet" type="button">Close</button>
|
<button id="close-pull-sheet" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
|
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
|
||||||
|
|
@ -1912,6 +1915,7 @@
|
||||||
<div class="small" id="review-sheet-key"></div>
|
<div class="small" id="review-sheet-key"></div>
|
||||||
<h3 id="review-sheet-title">Pull request review</h3>
|
<h3 id="review-sheet-title">Pull request review</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||||
<button class="review-action" id="close-review-sheet">Close</button>
|
<button class="review-action" id="close-review-sheet">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mobile-review-detail-nav" aria-label="Review sections">
|
<nav class="mobile-review-detail-nav" aria-label="Review sections">
|
||||||
|
|
@ -2183,6 +2187,16 @@
|
||||||
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||||
</section>
|
</section>
|
||||||
|
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
|
||||||
|
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
|
||||||
|
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
|
||||||
|
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
|
||||||
|
<button class="mobile-pinned-work-toggle" id="mobile-pinned-work-toggle" type="button" aria-controls="mobile-pinned-work-list" aria-expanded="false" hidden>Show all</button>
|
||||||
|
</section>
|
||||||
|
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
||||||
|
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
||||||
|
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
||||||
|
</section>
|
||||||
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
||||||
<summary>Customize routine order</summary>
|
<summary>Customize routine order</summary>
|
||||||
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
|
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
|
||||||
|
|
@ -2191,6 +2205,11 @@
|
||||||
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
|
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
|
||||||
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
|
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mobile-queue-priority-conflict" class="mobile-queue-priority-conflict" hidden>
|
||||||
|
<p class="small">This routine was changed on another device. Choose which complete order to keep.</p>
|
||||||
|
<button id="keep-local-mobile-queue-priority" type="button">Keep this device</button>
|
||||||
|
<button id="use-remote-mobile-queue-priority" type="button">Use other device</button>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
||||||
<h3 id="mobile-queue-active-heading">Active now</h3>
|
<h3 id="mobile-queue-active-heading">Active now</h3>
|
||||||
|
|
@ -2411,6 +2430,7 @@
|
||||||
<script src="static/mobile-pull-refresh.js"></script>
|
<script src="static/mobile-pull-refresh.js"></script>
|
||||||
<script src="static/mobile-first-task.js"></script>
|
<script src="static/mobile-first-task.js"></script>
|
||||||
<script src="static/mobile-work-entry.js"></script>
|
<script src="static/mobile-work-entry.js"></script>
|
||||||
|
<script src="static/mobile-recent-work.js"></script>
|
||||||
<script src="static/mobile-queue-priority.js"></script>
|
<script src="static/mobile-queue-priority.js"></script>
|
||||||
<script src="static/mobile-queue-launcher.js"></script>
|
<script src="static/mobile-queue-launcher.js"></script>
|
||||||
<script src="static/mobile-delivery-recovery.js"></script>
|
<script src="static/mobile-delivery-recovery.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,21 @@
|
||||||
];
|
];
|
||||||
const storage = options.storage;
|
const storage = options.storage;
|
||||||
const getLogin = options.getLogin || (() => '');
|
const getLogin = options.getLogin || (() => '');
|
||||||
|
const fetchJson = options.fetchJson;
|
||||||
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
||||||
const labels = options.labels || {};
|
const labels = options.labels || {};
|
||||||
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
||||||
|
const setTimer = options.setTimeout || setTimeout;
|
||||||
|
const clearTimer = options.clearTimeout || clearTimeout;
|
||||||
|
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||||
|
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
|
||||||
|
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
|
||||||
|
let memory = null;
|
||||||
|
const syncFlights = new Map();
|
||||||
|
let debounceTimer = null;
|
||||||
|
let retryTimer = null;
|
||||||
|
let retryAccount = '';
|
||||||
|
let retryAttempts = 0;
|
||||||
|
|
||||||
function key() {
|
function key() {
|
||||||
const login = String(getLogin() || '').trim().toLowerCase();
|
const login = String(getLogin() || '').trim().toLowerCase();
|
||||||
|
|
@ -19,32 +31,116 @@
|
||||||
function valid(order) {
|
function valid(order) {
|
||||||
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
|
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
|
||||||
new Set(order).size === DEFAULT_ORDER.length &&
|
new Set(order).size === DEFAULT_ORDER.length &&
|
||||||
order.every((name, index) => DEFAULT_ORDER.includes(name) && typeof name === 'string');
|
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrder() {
|
function fresh() {
|
||||||
|
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
const accountKey = key();
|
const accountKey = key();
|
||||||
if (!accountKey || !storage) return DEFAULT_ORDER.slice();
|
if (!accountKey || !storage) return fresh();
|
||||||
|
if (memory?.key === accountKey) return memory.value;
|
||||||
|
let value = fresh();
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
|
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
|
||||||
return valid(saved) ? saved.slice() : DEFAULT_ORDER.slice();
|
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
|
||||||
} catch (_error) {
|
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
|
||||||
return DEFAULT_ORDER.slice();
|
value = {
|
||||||
}
|
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
|
||||||
|
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
|
||||||
|
remote:saved.remote && valid(saved.remote.order) ? {
|
||||||
|
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
|
memory = {key:accountKey, value};
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save(order) {
|
function persist(value) {
|
||||||
const accountKey = key();
|
const accountKey = key();
|
||||||
if (!accountKey || !storage || !valid(order)) return false;
|
if (!accountKey || !storage) return false;
|
||||||
|
memory = {key:accountKey, value};
|
||||||
try {
|
try {
|
||||||
storage.setItem(accountKey, JSON.stringify(order));
|
storage.setItem(accountKey, JSON.stringify(value));
|
||||||
options.onChange?.(order.slice());
|
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
const value = read();
|
||||||
|
return {
|
||||||
|
revision:value.revision, order:value.order.slice(), pending:value.pending,
|
||||||
|
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(value) {
|
||||||
|
if (options.status) {
|
||||||
|
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
|
||||||
|
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
|
||||||
|
}
|
||||||
|
options.onState?.(snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrder() {
|
||||||
|
return read().order.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(order) {
|
||||||
|
if (!key() || !valid(order)) return false;
|
||||||
|
const current = read();
|
||||||
|
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
|
||||||
|
if (!persist(value)) return false;
|
||||||
|
options.onChange?.(order.slice());
|
||||||
|
announce(value);
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync() {
|
||||||
|
if (!fetchJson || !key()) return false;
|
||||||
|
if (debounceTimer) clearTimer(debounceTimer);
|
||||||
|
debounceTimer = setTimer(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
void sync();
|
||||||
|
}, debounceMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRetry() {
|
||||||
|
if (retryTimer) clearTimer(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
retryAccount = '';
|
||||||
|
retryAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transient(error) {
|
||||||
|
const status = Number(error?.status) || 0;
|
||||||
|
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(accountKey) {
|
||||||
|
if (retryTimer && retryAccount !== accountKey) clearRetry();
|
||||||
|
if (retryTimer || key() !== accountKey) return false;
|
||||||
|
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
|
||||||
|
retryAttempts += 1;
|
||||||
|
retryAccount = accountKey;
|
||||||
|
retryTimer = setTimer(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
retryAccount = '';
|
||||||
|
const current = read();
|
||||||
|
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
|
||||||
|
void sync();
|
||||||
|
}, delay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function move(name, delta) {
|
function move(name, delta) {
|
||||||
const order = getOrder();
|
const order = getOrder();
|
||||||
const index = order.indexOf(name);
|
const index = order.indexOf(name);
|
||||||
|
|
@ -56,15 +152,127 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
const accountKey = key();
|
|
||||||
if (accountKey && storage) {
|
|
||||||
try { storage.removeItem(accountKey); } catch (_error) {}
|
|
||||||
}
|
|
||||||
const order = DEFAULT_ORDER.slice();
|
const order = DEFAULT_ORDER.slice();
|
||||||
options.onChange?.(order.slice());
|
if (fetchJson && key()) save(order);
|
||||||
|
else {
|
||||||
|
const accountKey = key();
|
||||||
|
if (accountKey && storage) {
|
||||||
|
try { storage.removeItem(accountKey); } catch (_error) {}
|
||||||
|
}
|
||||||
|
memory = null;
|
||||||
|
options.onChange?.(order.slice());
|
||||||
|
}
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adopt(snapshotValue, accountKey = key()) {
|
||||||
|
if (!accountKey || key() !== accountKey) return snapshot();
|
||||||
|
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
|
||||||
|
throw new Error('Queue priority response is invalid.');
|
||||||
|
}
|
||||||
|
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
|
||||||
|
clearRetry();
|
||||||
|
persist(value);
|
||||||
|
options.onChange?.(value.order.slice());
|
||||||
|
announce(value);
|
||||||
|
render();
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const accountKey = key();
|
||||||
|
if (!fetchJson || !accountKey) return snapshot();
|
||||||
|
try {
|
||||||
|
const remote = await fetchJson('api/v1/queue-priority');
|
||||||
|
if (key() !== accountKey) return snapshot();
|
||||||
|
const local = read();
|
||||||
|
if (local.pending) {
|
||||||
|
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
|
||||||
|
persist(local);
|
||||||
|
return sync();
|
||||||
|
}
|
||||||
|
return adopt(remote, accountKey);
|
||||||
|
} catch (_error) {
|
||||||
|
if (key() !== accountKey) return snapshot();
|
||||||
|
const current = read();
|
||||||
|
current.status = current.pending ? 'pending' : 'error';
|
||||||
|
persist(current); announce(current);
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(accountKey) {
|
||||||
|
while (key() === accountKey) {
|
||||||
|
const current = read();
|
||||||
|
if (!current.pending) return snapshot();
|
||||||
|
const sent = {revision:current.revision, order:current.order.slice()};
|
||||||
|
current.status = 'syncing'; persist(current); announce(current);
|
||||||
|
try {
|
||||||
|
const saved = await fetchJson('api/v1/queue-priority', {
|
||||||
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify(sent),
|
||||||
|
});
|
||||||
|
if (key() !== accountKey) return snapshot();
|
||||||
|
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
|
||||||
|
throw new Error('Queue priority response is invalid.');
|
||||||
|
}
|
||||||
|
const latest = read();
|
||||||
|
if (latest.order.some((name, index) => name !== sent.order[index])) {
|
||||||
|
latest.revision = saved.revision; latest.pending = true;
|
||||||
|
latest.status = 'pending'; latest.remote = null;
|
||||||
|
persist(latest); announce(latest);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return adopt(saved, accountKey);
|
||||||
|
} catch (error) {
|
||||||
|
if (key() !== accountKey) return snapshot();
|
||||||
|
const latest = read();
|
||||||
|
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||||
|
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
|
||||||
|
clearRetry();
|
||||||
|
latest.status = 'conflict'; latest.pending = true;
|
||||||
|
latest.remote = {revision:remote.revision, order:remote.order.slice()};
|
||||||
|
} else {
|
||||||
|
latest.status = 'pending'; latest.pending = true;
|
||||||
|
if (transient(error)) scheduleRetry(accountKey);
|
||||||
|
}
|
||||||
|
persist(latest); announce(latest); render();
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimer(debounceTimer);
|
||||||
|
debounceTimer = null;
|
||||||
|
}
|
||||||
|
const accountKey = key();
|
||||||
|
const current = read();
|
||||||
|
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
|
||||||
|
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
|
||||||
|
const flight = drain(accountKey).finally(() => {
|
||||||
|
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
|
||||||
|
});
|
||||||
|
syncFlights.set(accountKey, flight);
|
||||||
|
return flight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useLocal() {
|
||||||
|
const current = read();
|
||||||
|
if (!current.remote) return snapshot();
|
||||||
|
current.revision = current.remote.revision;
|
||||||
|
current.remote = null; current.pending = true; current.status = 'pending';
|
||||||
|
persist(current);
|
||||||
|
return sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRemote() {
|
||||||
|
const current = read();
|
||||||
|
return current.remote ? adopt(current.remote) : snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
function displayName(name) {
|
function displayName(name) {
|
||||||
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
}
|
}
|
||||||
|
|
@ -82,42 +290,51 @@
|
||||||
const controls = documentRef.createElement('span');
|
const controls = documentRef.createElement('span');
|
||||||
controls.setAttribute('class', 'mobile-queue-priority-controls');
|
controls.setAttribute('class', 'mobile-queue-priority-controls');
|
||||||
const earlier = documentRef.createElement('button');
|
const earlier = documentRef.createElement('button');
|
||||||
earlier.textContent = 'Earlier';
|
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
|
||||||
earlier.setAttribute('type', 'button');
|
|
||||||
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
|
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
|
||||||
earlier.disabled = !signedIn || index === 0;
|
earlier.disabled = !signedIn || index === 0;
|
||||||
earlier.addEventListener('click', () => {
|
earlier.addEventListener('click', () => {
|
||||||
move(name, -1);
|
move(name, -1); render();
|
||||||
if (options.status) options.status.textContent = displayName(name) + ' moved earlier.';
|
|
||||||
render();
|
|
||||||
});
|
});
|
||||||
const later = documentRef.createElement('button');
|
const later = documentRef.createElement('button');
|
||||||
later.textContent = 'Later';
|
later.textContent = 'Later'; later.setAttribute('type', 'button');
|
||||||
later.setAttribute('type', 'button');
|
|
||||||
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
|
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
|
||||||
later.disabled = !signedIn || index === order.length - 1;
|
later.disabled = !signedIn || index === order.length - 1;
|
||||||
later.addEventListener('click', () => {
|
later.addEventListener('click', () => {
|
||||||
move(name, 1);
|
move(name, 1); render();
|
||||||
if (options.status) options.status.textContent = displayName(name) + ' moved later.';
|
|
||||||
render();
|
|
||||||
});
|
});
|
||||||
controls.append(earlier, later);
|
controls.append(earlier, later); row.append(label, controls);
|
||||||
row.append(label, controls);
|
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
options.list.replaceChildren(...rows);
|
options.list.replaceChildren(...rows);
|
||||||
if (options.resetButton) options.resetButton.disabled = !signedIn;
|
if (options.resetButton) options.resetButton.disabled = !signedIn;
|
||||||
|
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
options.resetButton?.addEventListener('click', () => {
|
options.resetButton?.addEventListener('click', () => {
|
||||||
reset();
|
reset(); render();
|
||||||
if (options.status) options.status.textContent = 'Routine order reset.';
|
|
||||||
render();
|
|
||||||
});
|
});
|
||||||
|
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
|
||||||
|
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
|
||||||
return render();
|
return render();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {getOrder, move, reset, render, start, defaultOrder: () => DEFAULT_ORDER.slice()};
|
function startLifecycle(lifecycle = {}) {
|
||||||
|
const windowObject = lifecycle.window;
|
||||||
|
const lifecycleDocument = lifecycle.document;
|
||||||
|
const reconcile = () => {
|
||||||
|
if (!key()) return Promise.resolve(snapshot());
|
||||||
|
return read().pending ? sync() : load();
|
||||||
|
};
|
||||||
|
windowObject?.addEventListener?.('online', () => { void reconcile(); });
|
||||||
|
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
|
||||||
|
if (!lifecycleDocument.hidden) void reconcile();
|
||||||
|
});
|
||||||
|
return reconcile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
|
||||||
|
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
455
frontend/mobile-recent-work.js
Normal file
455
frontend/mobile-recent-work.js
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
|
else root.createMobileRecentWork = factory;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const storage = options.storage;
|
||||||
|
const getLogin = options.getLogin;
|
||||||
|
const fetchJson = options.fetchJson;
|
||||||
|
const limit = Math.max(1, Number(options.limit) || 5);
|
||||||
|
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
|
||||||
|
const prefix = 'stackchain.mobile-recent-work.v1.';
|
||||||
|
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||||
|
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
||||||
|
const setTimer = options.setTimeout || setTimeout;
|
||||||
|
const clearTimer = options.clearTimeout || clearTimeout;
|
||||||
|
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||||
|
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
|
||||||
|
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
|
||||||
|
let syncFlight = null;
|
||||||
|
let syncAccount = '';
|
||||||
|
let debounceTimer = null;
|
||||||
|
let retryTimer = null;
|
||||||
|
let retryAccount = '';
|
||||||
|
let retryAttempt = 0;
|
||||||
|
let operationSequence = 0;
|
||||||
|
let pinsExpanded = false;
|
||||||
|
let currentItem = null;
|
||||||
|
|
||||||
|
const detailPins = Array.from(options.detailPins || []);
|
||||||
|
detailPins.forEach(button => button.addEventListener?.('click', () => {
|
||||||
|
if (!currentItem) return;
|
||||||
|
const isPinned = pinned().some(item => item.route === currentItem.route);
|
||||||
|
if (isPinned) unpin(currentItem.route);
|
||||||
|
else pin(currentItem);
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.pinnedToggle?.addEventListener?.('click', () => {
|
||||||
|
pinsExpanded = !pinsExpanded;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
function operationId() {
|
||||||
|
operationSequence += 1;
|
||||||
|
return Date.now().toString(36) + '-' + operationSequence.toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRetry(resetAttempt = false) {
|
||||||
|
if (retryTimer) clearTimer(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
retryAccount = '';
|
||||||
|
if (resetAttempt) retryAttempt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(accountKey) {
|
||||||
|
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
|
||||||
|
retryAccount = accountKey;
|
||||||
|
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
|
||||||
|
retryAttempt += 1;
|
||||||
|
retryTimer = setTimer(() => {
|
||||||
|
const timer = retryTimer;
|
||||||
|
retryTimer = null;
|
||||||
|
retryAccount = '';
|
||||||
|
if (timer) clearTimer(timer);
|
||||||
|
if (key() === accountKey && hasPending(read())) void sync();
|
||||||
|
}, delay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
return String(getLogin?.() || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
const owner = login();
|
||||||
|
return owner ? prefix + owner : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(item) {
|
||||||
|
const kind = String(item?.kind || '');
|
||||||
|
const number = Number(item?.number ?? item?.notification_id);
|
||||||
|
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
|
||||||
|
let route = '';
|
||||||
|
let repository = '';
|
||||||
|
if (kind === 'update') {
|
||||||
|
route = '#/my-work/update/' + number;
|
||||||
|
} else {
|
||||||
|
repository = String(item?.repository || '');
|
||||||
|
if (!repositoryPattern.test(repository)) return null;
|
||||||
|
route = '#/my-work/' + kind + '/' + repository + '/' + number;
|
||||||
|
}
|
||||||
|
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
|
||||||
|
if (!title) return null;
|
||||||
|
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(value, maximum = limit) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const unique = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
const item = normalize(candidate);
|
||||||
|
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
||||||
|
if (unique.length === maximum) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePinOps(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const unique = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
const action = candidate?.action;
|
||||||
|
const item = action === 'pin' ? normalize(candidate.item) : null;
|
||||||
|
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
|
||||||
|
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
|
||||||
|
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
|
||||||
|
const normalized = action === 'pin' ? {action, item} : {action, route};
|
||||||
|
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
|
||||||
|
unique.push(normalized);
|
||||||
|
}
|
||||||
|
if (unique.length === pinnedLimit) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePending(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const unique = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
const item = normalize(candidate);
|
||||||
|
if (!item || unique.some(existing => existing.route === item.route)) continue;
|
||||||
|
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
|
||||||
|
unique.push(item);
|
||||||
|
if (unique.length === limit) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty() {
|
||||||
|
return {items:[], pinned:[], pending:[], pinOps:[]};
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey) return empty();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||||
|
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
|
||||||
|
return {
|
||||||
|
items:normalizeList(parsed?.items),
|
||||||
|
pinned:normalizeList(parsed?.pinned, pinnedLimit),
|
||||||
|
pending:normalizePending(parsed?.pending),
|
||||||
|
pinOps:normalizePinOps(parsed?.pinOps),
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(value, accountKey = key()) {
|
||||||
|
if (!accountKey || accountKey !== key()) return false;
|
||||||
|
try {
|
||||||
|
storage.setItem(accountKey, JSON.stringify({
|
||||||
|
items:normalizeList(value.items),
|
||||||
|
pinned:normalizeList(value.pinned, pinnedLimit),
|
||||||
|
pending:normalizePending(value.pending),
|
||||||
|
pinOps:normalizePinOps(value.pinOps),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPending(value) {
|
||||||
|
return value.pending.length > 0 || value.pinOps.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(value = read(), status = null) {
|
||||||
|
if (!options.status) return;
|
||||||
|
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function items() {
|
||||||
|
return read().items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinned() {
|
||||||
|
return read().pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function state() {
|
||||||
|
const value = read();
|
||||||
|
const pendingCount = value.pending.length + value.pinOps.length;
|
||||||
|
return {pending:pendingCount > 0, pendingCount};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync() {
|
||||||
|
if (!fetchJson || !key()) return false;
|
||||||
|
if (debounceTimer) clearTimer(debounceTimer);
|
||||||
|
debounceTimer = setTimer(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
void sync();
|
||||||
|
}, debounceMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(item) {
|
||||||
|
const accountKey = key();
|
||||||
|
const normalized = normalize(item);
|
||||||
|
if (!accountKey || !normalized) return false;
|
||||||
|
const current = read();
|
||||||
|
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||||
|
current.pinned = current.pinned.some(existing => existing.route === normalized.route)
|
||||||
|
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
|
||||||
|
: current.pinned;
|
||||||
|
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||||
|
if (!persist(current, accountKey)) return false;
|
||||||
|
announce(current);
|
||||||
|
render();
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePinOp(current, operation) {
|
||||||
|
const route = operation.item?.route || operation.route;
|
||||||
|
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pin(item) {
|
||||||
|
const accountKey = key();
|
||||||
|
const normalized = normalize(item);
|
||||||
|
if (!accountKey || !normalized) return false;
|
||||||
|
const current = read();
|
||||||
|
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
|
||||||
|
queuePinOp(current, {action:'pin', item:normalized});
|
||||||
|
if (!persist(current, accountKey)) return false;
|
||||||
|
announce(current);
|
||||||
|
render();
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpin(route) {
|
||||||
|
const accountKey = key();
|
||||||
|
route = String(route || '');
|
||||||
|
if (!accountKey || !route) return false;
|
||||||
|
const current = read();
|
||||||
|
if (!current.pinned.some(item => item.route === route)) return false;
|
||||||
|
current.pinned = current.pinned.filter(item => item.route !== route);
|
||||||
|
queuePinOp(current, {action:'unpin', route});
|
||||||
|
if (!persist(current, accountKey)) return false;
|
||||||
|
announce(current);
|
||||||
|
render();
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPinOps(remote, operations) {
|
||||||
|
let result = normalizeList(remote, pinnedLimit);
|
||||||
|
for (const operation of [...normalizePinOps(operations)].reverse()) {
|
||||||
|
const route = operation.item?.route || operation.route;
|
||||||
|
result = operation.action === 'pin'
|
||||||
|
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
|
||||||
|
: result.filter(item => item.route !== route);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
|
||||||
|
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
||||||
|
const remote = normalizeList(snapshot.items);
|
||||||
|
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
|
||||||
|
const unsent = normalizePending(pending);
|
||||||
|
const unsentPinOps = normalizePinOps(pinOps);
|
||||||
|
const value = {
|
||||||
|
items:normalizeList([...unsent, ...remote]),
|
||||||
|
pinned:applyPinOps(remotePinned, unsentPinOps),
|
||||||
|
pending:unsent,
|
||||||
|
pinOps:unsentPinOps,
|
||||||
|
};
|
||||||
|
persist(value, accountKey);
|
||||||
|
announce(value);
|
||||||
|
render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(accountKey) {
|
||||||
|
while (key() === accountKey) {
|
||||||
|
const current = read();
|
||||||
|
if (!hasPending(current)) return current;
|
||||||
|
const sending = current.pending[current.pending.length - 1];
|
||||||
|
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
|
||||||
|
announce(current, 'Syncing recent work…');
|
||||||
|
try {
|
||||||
|
let snapshot;
|
||||||
|
if (sending) {
|
||||||
|
snapshot = await fetchJson('api/v1/recent-work', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const isPin = pinOperation.action === 'pin';
|
||||||
|
snapshot = await fetchJson('api/v1/recent-work/pin', {
|
||||||
|
method:isPin ? 'PUT' : 'DELETE',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key() !== accountKey) return read();
|
||||||
|
const latest = read();
|
||||||
|
const pending = sending
|
||||||
|
? latest.pending.filter(item => item.operationId !== sending.operationId)
|
||||||
|
: latest.pending;
|
||||||
|
const pinOps = pinOperation
|
||||||
|
? latest.pinOps.filter(operation => {
|
||||||
|
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
|
||||||
|
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
|
||||||
|
})
|
||||||
|
: latest.pinOps;
|
||||||
|
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
|
||||||
|
retryAttempt = 0;
|
||||||
|
} catch (_error) {
|
||||||
|
if (key() === accountKey) {
|
||||||
|
announce(read());
|
||||||
|
scheduleRetry(accountKey);
|
||||||
|
}
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
||||||
|
const accountKey = key();
|
||||||
|
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
|
||||||
|
else if (retryTimer) clearRetry(false);
|
||||||
|
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
|
||||||
|
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
||||||
|
syncAccount = accountKey;
|
||||||
|
syncFlight = drain(accountKey).finally(() => {
|
||||||
|
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
|
||||||
|
});
|
||||||
|
return syncFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const accountKey = key();
|
||||||
|
if (!fetchJson || !accountKey) return read();
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchJson('api/v1/recent-work');
|
||||||
|
if (key() !== accountKey) return read();
|
||||||
|
const current = read();
|
||||||
|
adopt(snapshot, accountKey, current.pending, current.pinOps);
|
||||||
|
return hasPending(current) ? sync() : read();
|
||||||
|
} catch (_error) {
|
||||||
|
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLifecycle(lifecycle = {}) {
|
||||||
|
const reconcile = () => hasPending(read()) ? sync() : load();
|
||||||
|
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
||||||
|
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
||||||
|
if (!lifecycle.document.hidden) void reconcile();
|
||||||
|
});
|
||||||
|
return reconcile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail(item) {
|
||||||
|
return item.kind === 'update'
|
||||||
|
? 'Update · #' + item.number
|
||||||
|
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(item, isPinned) {
|
||||||
|
const itemDetail = detail(item);
|
||||||
|
const wrapper = options.document.createElement('div');
|
||||||
|
const button = options.document.createElement('button');
|
||||||
|
const action = options.document.createElement('button');
|
||||||
|
const copy = options.document.createElement('span');
|
||||||
|
const primary = options.document.createElement('strong');
|
||||||
|
const secondary = options.document.createElement('small');
|
||||||
|
wrapper.setAttribute('class', 'mobile-recent-work-row');
|
||||||
|
primary.textContent = item.title;
|
||||||
|
secondary.textContent = itemDetail;
|
||||||
|
copy.appendChild(primary);
|
||||||
|
copy.appendChild(secondary);
|
||||||
|
button.appendChild(copy);
|
||||||
|
button.setAttribute('type', 'button');
|
||||||
|
button.setAttribute('data-recent-work-route', item.route);
|
||||||
|
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
if (isPinned) record(item);
|
||||||
|
options.openRoute?.(item.route);
|
||||||
|
});
|
||||||
|
action.textContent = isPinned ? 'Unpin' : 'Pin';
|
||||||
|
action.setAttribute('type', 'button');
|
||||||
|
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
|
||||||
|
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
|
||||||
|
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
|
||||||
|
wrapper.appendChild(button);
|
||||||
|
wrapper.appendChild(action);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrent() {
|
||||||
|
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
|
||||||
|
detailPins.forEach(button => {
|
||||||
|
button.hidden = !currentItem;
|
||||||
|
if (!currentItem) return;
|
||||||
|
button.textContent = isPinned ? 'Pinned' : 'Pin';
|
||||||
|
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
|
||||||
|
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
|
||||||
|
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrent(item) {
|
||||||
|
currentItem = normalize(item);
|
||||||
|
renderCurrent();
|
||||||
|
return Boolean(currentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const recent = items();
|
||||||
|
const fixed = pinned();
|
||||||
|
const fixedRoutes = new Set(fixed.map(item => item.route));
|
||||||
|
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
|
||||||
|
const list = options.list;
|
||||||
|
const section = options.section;
|
||||||
|
if (list && section && options.document) {
|
||||||
|
const rows = visibleRecent.map(item => row(item, false));
|
||||||
|
list.replaceChildren(...rows);
|
||||||
|
section.hidden = rows.length === 0;
|
||||||
|
}
|
||||||
|
if (options.pinnedList && options.pinnedSection && options.document) {
|
||||||
|
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
|
||||||
|
const rows = visiblePins.map(item => row(item, true));
|
||||||
|
options.pinnedList.replaceChildren(...rows);
|
||||||
|
options.pinnedSection.hidden = rows.length === 0;
|
||||||
|
}
|
||||||
|
if (options.pinnedToggle) {
|
||||||
|
options.pinnedToggle.hidden = fixed.length <= 3;
|
||||||
|
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
|
||||||
|
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
|
||||||
|
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
|
||||||
|
}
|
||||||
|
renderCurrent();
|
||||||
|
return visibleRecent.length + fixed.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
|
||||||
|
});
|
||||||
|
|
@ -233,6 +233,7 @@
|
||||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||||
source_branch_deleted: 'Source branch deleted',
|
source_branch_deleted: 'Source branch deleted',
|
||||||
release_rollback_prepared: 'Release rollback prepared',
|
release_rollback_prepared: 'Release rollback prepared',
|
||||||
|
ci_job_retried: 'CI job retried',
|
||||||
comment_deleted: 'Comment deleted',
|
comment_deleted: 'Comment deleted',
|
||||||
pull_review_approved: 'Pull request approved',
|
pull_review_approved: 'Pull request approved',
|
||||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,7 @@ const SHELL = [
|
||||||
BASE + 'static/mobile-task-dock.js',
|
BASE + 'static/mobile-task-dock.js',
|
||||||
BASE + 'static/mobile-first-task.js',
|
BASE + 'static/mobile-first-task.js',
|
||||||
BASE + 'static/mobile-work-entry.js',
|
BASE + 'static/mobile-work-entry.js',
|
||||||
|
BASE + 'static/mobile-recent-work.js',
|
||||||
BASE + 'static/mobile-queue-priority.js',
|
BASE + 'static/mobile-queue-priority.js',
|
||||||
BASE + 'static/mobile-queue-launcher.js',
|
BASE + 'static/mobile-queue-launcher.js',
|
||||||
BASE + 'static/mobile-delivery-recovery.js',
|
BASE + 'static/mobile-delivery-recovery.js',
|
||||||
|
|
@ -301,6 +302,8 @@ const SHELL = [
|
||||||
];
|
];
|
||||||
const OPTIONAL_FEATURES = [
|
const OPTIONAL_FEATURES = [
|
||||||
];
|
];
|
||||||
|
const DEMAND_FEATURES = [
|
||||||
|
];
|
||||||
|
|
||||||
const SHARED_IMAGE_ID = 'shared-image';
|
const SHARED_IMAGE_ID = 'shared-image';
|
||||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||||
|
|
@ -1027,7 +1030,8 @@ self.addEventListener('fetch', event => {
|
||||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
if (url.origin === self.location.origin &&
|
||||||
|
(OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) {
|
||||||
event.respondWith(cachedOptionalFeature(request));
|
event.respondWith(cachedOptionalFeature(request));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
const snapshot = timer.snapshot();
|
const snapshot = timer.snapshot();
|
||||||
breakView?.render();
|
breakView?.render();
|
||||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
queryAll('[data-mobile-today-hud]').forEach(element => {
|
||||||
|
const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])');
|
||||||
|
element.hidden = !active && !coaching;
|
||||||
|
});
|
||||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -338,8 +338,18 @@ function mountTodayWeekReschedule({
|
||||||
confirm.addEventListener('click',async()=>{
|
confirm.addEventListener('click',async()=>{
|
||||||
if(!selectedDate)return;
|
if(!selectedDate)return;
|
||||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
|
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
|
||||||
|
let result;
|
||||||
|
try{
|
||||||
|
result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||||
|
}catch(error){
|
||||||
|
if(!dialog.open)dialog.showModal();
|
||||||
|
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||||
|
status.textContent=error.message;
|
||||||
|
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||||
|
confirm.disabled=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try{
|
try{
|
||||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
|
||||||
if(result.sync_pending){
|
if(result.sync_pending){
|
||||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||||
}else{
|
}else{
|
||||||
|
|
@ -347,11 +357,7 @@ function mountTodayWeekReschedule({
|
||||||
}
|
}
|
||||||
await continueToday();
|
await continueToday();
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(!dialog.open)dialog.showModal();
|
announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
|
||||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
|
||||||
status.textContent=error.message;
|
|
||||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
|
||||||
confirm.disabled=false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {controller,close,flushPending,resumePending};
|
return {controller,close,flushPending,resumePending};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ async function loadWorkspace({
|
||||||
const failed = new Set();
|
const failed = new Set();
|
||||||
const recoveries = new Map();
|
const recoveries = new Map();
|
||||||
let retryInFlight = null;
|
let retryInFlight = null;
|
||||||
|
let resolveWorkspaceReady;
|
||||||
|
const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; });
|
||||||
|
let workspaceMarkedReady = false;
|
||||||
|
const markWorkspaceReady = () => {
|
||||||
|
if (workspaceMarkedReady) return false;
|
||||||
|
workspaceMarkedReady = true;
|
||||||
|
resolveWorkspaceReady(true);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const loadFeature = name => {
|
const loadFeature = name => {
|
||||||
attempts[name] += 1;
|
attempts[name] += 1;
|
||||||
|
|
@ -63,6 +72,8 @@ async function loadWorkspace({
|
||||||
retryButton?.addEventListener?.('click', retryFailed);
|
retryButton?.addEventListener?.('click', retryFailed);
|
||||||
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||||
window?.addEventListener('online', handleOnline);
|
window?.addEventListener('online', handleOnline);
|
||||||
|
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
|
||||||
|
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await retryOnce('work-core');
|
await retryOnce('work-core');
|
||||||
|
|
@ -73,23 +84,56 @@ async function loadWorkspace({
|
||||||
}
|
}
|
||||||
hideRecovery();
|
hideRecovery();
|
||||||
|
|
||||||
const optional = ['today-timer', 'planning'].map(async name => {
|
let optionalReady = null;
|
||||||
try {
|
const hydrateWorkspace = () => {
|
||||||
await retryOnce(name);
|
if (optionalReady) return optionalReady;
|
||||||
return true;
|
let resolveOptional;
|
||||||
} catch (_error) {
|
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
|
||||||
failed.add(name);
|
const optional = ['today-timer', 'planning'].map(async name => {
|
||||||
showRecovery();
|
try {
|
||||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
await retryOnce(name);
|
||||||
}
|
return true;
|
||||||
});
|
} catch (_error) {
|
||||||
const optionalReady = Promise.all(optional).then(() => {
|
failed.add(name);
|
||||||
hideRecovery();
|
showRecovery();
|
||||||
return true;
|
return new Promise(resolve => recoveries.set(name, {resolve}));
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
Promise.all(optional).then(() => {
|
||||||
|
hideRecovery();
|
||||||
|
resolveOptional(true);
|
||||||
|
});
|
||||||
|
return optionalReady;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hydrationSelector = [
|
||||||
|
'[data-mobile-task]:not([data-mobile-task="queues"])',
|
||||||
|
'[data-progressive-loading="true"]',
|
||||||
|
'#app-menu-toggle',
|
||||||
|
'#work-settings-toggle',
|
||||||
|
].join(',');
|
||||||
|
const hydrateForAction = async event => {
|
||||||
|
const target = event.target?.closest?.(hydrationSelector);
|
||||||
|
if (!target) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
await hydrateWorkspace();
|
||||||
|
await workspaceReady;
|
||||||
|
document.removeEventListener?.('click', hydrateForAction, true);
|
||||||
|
target.click?.();
|
||||||
|
};
|
||||||
|
document.addEventListener?.('click', hydrateForAction, true);
|
||||||
|
const hash = window?.location?.hash || '';
|
||||||
|
const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ?
|
||||||
|
hydrateWorkspace() : Promise.resolve(false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
optionalReady,
|
hydrateWorkspace,
|
||||||
|
deepLinkReady,
|
||||||
|
workspaceReady,
|
||||||
|
markWorkspaceReady,
|
||||||
|
serviceWorkerReady,
|
||||||
|
get optionalReady() { return hydrateWorkspace(); },
|
||||||
retryFeature(name) {
|
retryFeature(name) {
|
||||||
if (!failed.has(name)) return Promise.resolve(true);
|
if (!failed.has(name)) return Promise.resolve(true);
|
||||||
return retryFailed().then(() => !failed.has(name));
|
return retryFailed().then(() => !failed.has(name));
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ STORES = (
|
||||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||||
)),
|
)),
|
||||||
|
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
|
||||||
|
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
|
||||||
|
)),
|
||||||
|
Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", (
|
||||||
|
Table("recent_work", ("login",), (Field("items", "items:{login}"),)),
|
||||||
|
)),
|
||||||
Store(
|
Store(
|
||||||
"completed-filed-reviews",
|
"completed-filed-reviews",
|
||||||
"completed-filed-reviews",
|
"completed-filed-reviews",
|
||||||
|
|
|
||||||
35
src/disk_capacity.py
Normal file
35
src/disk_capacity.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Disk-capacity incident assessment shared by operations checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from os import PathLike
|
||||||
|
|
||||||
|
|
||||||
|
def assess_disk_capacity(
|
||||||
|
*,
|
||||||
|
total_bytes: int,
|
||||||
|
available_bytes: int,
|
||||||
|
threshold_percent: float = 85.0,
|
||||||
|
) -> dict[str, float | bool]:
|
||||||
|
"""Return the capacity status using the runbook's inclusive threshold."""
|
||||||
|
usage_percent = round((total_bytes - available_bytes) / total_bytes * 100, 1)
|
||||||
|
return {
|
||||||
|
"usage_percent": usage_percent,
|
||||||
|
"threshold_percent": threshold_percent,
|
||||||
|
"incident": usage_percent >= threshold_percent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_disk_capacity(
|
||||||
|
path: str | PathLike[str] = "/",
|
||||||
|
*,
|
||||||
|
threshold_percent: float = 85.0,
|
||||||
|
) -> dict[str, float | bool]:
|
||||||
|
"""Assess capacity for a real filesystem path."""
|
||||||
|
usage = shutil.disk_usage(path)
|
||||||
|
return assess_disk_capacity(
|
||||||
|
total_bytes=usage.total,
|
||||||
|
available_bytes=usage.free,
|
||||||
|
threshold_percent=threshold_percent,
|
||||||
|
)
|
||||||
|
|
@ -44,7 +44,7 @@ FEATURE_SOURCES = {
|
||||||
),
|
),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
|
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
|
||||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-recent-work.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
|
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
|
@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if source != WORKER_RUNTIME_SOURCE:
|
if source != WORKER_RUNTIME_SOURCE:
|
||||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||||
optional_features = feature_bundles
|
# The workspace hydrator fetches these large chunks only when a route or action
|
||||||
|
# needs them; warming them here would defeat demand loading on every visit.
|
||||||
|
optional_features = {
|
||||||
|
name: bundle for name, bundle in feature_bundles.items()
|
||||||
|
if name not in {"today-timer", "planning"}
|
||||||
|
}
|
||||||
|
demand_features = {
|
||||||
|
name: bundle for name, bundle in feature_bundles.items()
|
||||||
|
if name in {"today-timer", "planning"}
|
||||||
|
}
|
||||||
worker = worker.replace(
|
worker = worker.replace(
|
||||||
" BASE + 'static/dashboard.css',\n",
|
" BASE + 'static/dashboard.css',\n",
|
||||||
" BASE + 'static/dashboard.css',\n"
|
" BASE + 'static/dashboard.css',\n"
|
||||||
|
|
@ -151,6 +160,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
"const OPTIONAL_FEATURES = [\n"
|
"const OPTIONAL_FEATURES = [\n"
|
||||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||||
)
|
)
|
||||||
|
worker = worker.replace(
|
||||||
|
"const DEMAND_FEATURES = [\n",
|
||||||
|
"const DEMAND_FEATURES = [\n"
|
||||||
|
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()),
|
||||||
|
)
|
||||||
worker = CACHE_DECLARATION.sub(
|
worker = CACHE_DECLARATION.sub(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||||
)
|
)
|
||||||
|
|
|
||||||
255
src/main.py
255
src/main.py
|
|
@ -71,6 +71,8 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
|
||||||
from src.push_subscription_store import build_push_subscription_store
|
from src.push_subscription_store import build_push_subscription_store
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
|
from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
|
||||||
|
from src.recent_work_store import RecentWorkStore
|
||||||
from src.following_store import FollowingStore
|
from src.following_store import FollowingStore
|
||||||
from src.unfiled_draft_store import (
|
from src.unfiled_draft_store import (
|
||||||
UnfiledDraftConflict,
|
UnfiledDraftConflict,
|
||||||
|
|
@ -496,6 +498,15 @@ async def _upstream_identity() -> tuple[int, str]:
|
||||||
return principal_id, principal_login
|
return principal_id, principal_login
|
||||||
|
|
||||||
|
|
||||||
|
async def _security_principal_id(request: Request) -> int:
|
||||||
|
session = getattr(request.state, "dashboard_session", None)
|
||||||
|
principal_id = getattr(session, "principal_id", None)
|
||||||
|
if isinstance(principal_id, int) and not isinstance(principal_id, bool) and principal_id > 0:
|
||||||
|
return principal_id
|
||||||
|
principal_id, _login = await _upstream_identity()
|
||||||
|
return principal_id
|
||||||
|
|
||||||
|
|
||||||
class DashboardSignIn(BaseModel):
|
class DashboardSignIn(BaseModel):
|
||||||
access_token: str = Field(min_length=1, max_length=1_024)
|
access_token: str = Field(min_length=1, max_length=1_024)
|
||||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
||||||
|
|
@ -595,6 +606,7 @@ StepUpAction = Literal[
|
||||||
"merge_pull",
|
"merge_pull",
|
||||||
"delete_source_branch",
|
"delete_source_branch",
|
||||||
"prepare_release_rollback",
|
"prepare_release_rollback",
|
||||||
|
"retry_ci_job",
|
||||||
"submit_pull_review",
|
"submit_pull_review",
|
||||||
"close_issue",
|
"close_issue",
|
||||||
"delete_comment",
|
"delete_comment",
|
||||||
|
|
@ -956,6 +968,23 @@ class SavedSearchCollection(BaseModel):
|
||||||
views: list[SavedSearchView] = Field(max_length=20)
|
views: list[SavedSearchView] = Field(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class QueuePriorityCollection(BaseModel):
|
||||||
|
revision: int = Field(ge=0)
|
||||||
|
order: list[str] = Field(min_length=9, max_length=9)
|
||||||
|
|
||||||
|
|
||||||
|
class RecentWorkItem(BaseModel):
|
||||||
|
kind: Literal["issue", "filed", "pull", "review", "update"]
|
||||||
|
repository: str = Field(default="", max_length=200)
|
||||||
|
number: PositiveInt
|
||||||
|
title: str = Field(min_length=1, max_length=180)
|
||||||
|
route: str = Field(min_length=1, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class RecentWorkRoute(BaseModel):
|
||||||
|
route: str = Field(min_length=1, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
class CompletedFiledReviewReceipt(BaseModel):
|
class CompletedFiledReviewReceipt(BaseModel):
|
||||||
repository: str = Field(
|
repository: str = Field(
|
||||||
min_length=3,
|
min_length=3,
|
||||||
|
|
@ -1775,7 +1804,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(request)
|
path = dashboard_auth.application_path(request)
|
||||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/recent-work", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -1929,6 +1958,7 @@ async def decide_human_gate(
|
||||||
target=gate_id,
|
target=gate_id,
|
||||||
)
|
)
|
||||||
login = await _human_gate_login(request)
|
login = await _human_gate_login(request)
|
||||||
|
principal_id = int(login.partition(":")[0])
|
||||||
journal = _security_event_store()
|
journal = _security_event_store()
|
||||||
operation_id = None
|
operation_id = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -1939,6 +1969,7 @@ async def decide_human_gate(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"human_gate_decision",
|
"human_gate_decision",
|
||||||
|
principal_id=principal_id,
|
||||||
method=payload.decision,
|
method=payload.decision,
|
||||||
target=gate_id,
|
target=gate_id,
|
||||||
)
|
)
|
||||||
|
|
@ -2062,6 +2093,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_security_event_store().record,
|
_security_event_store().record,
|
||||||
"sign_in",
|
"sign_in",
|
||||||
|
principal_id=principal_id,
|
||||||
method="token",
|
method="token",
|
||||||
device_label=payload.device_label,
|
device_label=payload.device_label,
|
||||||
target="dashboard",
|
target="dashboard",
|
||||||
|
|
@ -2099,12 +2131,16 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||||
|
|
||||||
@app.get("/api/v1/security-events")
|
@app.get("/api/v1/security-events")
|
||||||
async def list_security_events(
|
async def list_security_events(
|
||||||
|
request: Request,
|
||||||
limit: int = Query(default=25, ge=1, le=100),
|
limit: int = Query(default=25, ge=1, le=100),
|
||||||
cursor: int | None = Query(default=None, ge=1),
|
cursor: int | None = Query(default=None, ge=1),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
page = await asyncio.to_thread(
|
page = await asyncio.to_thread(
|
||||||
_security_event_store().list, limit=limit, cursor=cursor
|
_security_event_store().list,
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
)
|
)
|
||||||
authentication_alerts = await asyncio.to_thread(
|
authentication_alerts = await asyncio.to_thread(
|
||||||
_login_attempt_store().list_alerts, limit=24
|
_login_attempt_store().list_alerts, limit=24
|
||||||
|
|
@ -2270,6 +2306,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"passkey_enrolled",
|
"passkey_enrolled",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
method="passkey",
|
method="passkey",
|
||||||
device_label=current.device_label,
|
device_label=current.device_label,
|
||||||
target="passkey",
|
target="passkey",
|
||||||
|
|
@ -2381,6 +2418,7 @@ async def revoke_enrolled_passkey(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"passkey_revoked",
|
"passkey_revoked",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
device_label=credential.device_label,
|
device_label=credential.device_label,
|
||||||
target="passkey",
|
target="passkey",
|
||||||
)
|
)
|
||||||
|
|
@ -2556,6 +2594,7 @@ async def verify_passkey_authorization(
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_security_event_store().record,
|
_security_event_store().record,
|
||||||
"passkey_counter_anomaly",
|
"passkey_counter_anomaly",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
method="passkey",
|
method="passkey",
|
||||||
device_label=stored.device_label,
|
device_label=stored.device_label,
|
||||||
target=f"{payload.action}:{payload.target}",
|
target=f"{payload.action}:{payload.target}",
|
||||||
|
|
@ -2667,6 +2706,7 @@ async def verify_passkey_authentication(
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_security_event_store().record,
|
_security_event_store().record,
|
||||||
"passkey_counter_anomaly",
|
"passkey_counter_anomaly",
|
||||||
|
principal_id=stored.principal_id,
|
||||||
method="passkey",
|
method="passkey",
|
||||||
device_label=stored.device_label,
|
device_label=stored.device_label,
|
||||||
target="sign_in:dashboard",
|
target="sign_in:dashboard",
|
||||||
|
|
@ -2705,6 +2745,7 @@ async def verify_passkey_authentication(
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_security_event_store().record,
|
_security_event_store().record,
|
||||||
"sign_in",
|
"sign_in",
|
||||||
|
principal_id=stored.principal_id,
|
||||||
method="passkey",
|
method="passkey",
|
||||||
device_label=stored.device_label,
|
device_label=stored.device_label,
|
||||||
target="dashboard",
|
target="dashboard",
|
||||||
|
|
@ -3081,6 +3122,18 @@ def _saved_search_store() -> SavedSearchStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_priority_store() -> QueuePriorityStore:
|
||||||
|
return QueuePriorityStore(
|
||||||
|
os.getenv("STACKCHAIN_QUEUE_PRIORITY_DB", str(_state_dir / "queue-priority.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_work_store() -> RecentWorkStore:
|
||||||
|
return RecentWorkStore(
|
||||||
|
os.getenv("STACKCHAIN_RECENT_WORK_DB", str(_state_dir / "recent-work.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _following_store() -> FollowingStore:
|
def _following_store() -> FollowingStore:
|
||||||
return FollowingStore(
|
return FollowingStore(
|
||||||
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
||||||
|
|
@ -3348,6 +3401,112 @@ async def replace_saved_searches(payload: SavedSearchCollection):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/queue-priority")
|
||||||
|
async def get_queue_priority(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_queue_priority_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Queue priority synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/queue-priority")
|
||||||
|
async def replace_queue_priority(payload: QueuePriorityCollection):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_queue_priority_store().replace, login, payload.revision, payload.order
|
||||||
|
)
|
||||||
|
except QueuePriorityConflict as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Queue priority changed on another device.",
|
||||||
|
"snapshot": exc.snapshot,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Queue priority synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/recent-work")
|
||||||
|
async def get_recent_work(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_recent_work_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/recent-work")
|
||||||
|
async def record_recent_work(payload: RecentWorkItem):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_recent_work_store().record, login, payload.model_dump()
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/recent-work/pin")
|
||||||
|
async def pin_recent_work(payload: RecentWorkItem):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_recent_work_store().pin, login, payload.model_dump()
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/recent-work/pin")
|
||||||
|
async def unpin_recent_work(payload: RecentWorkRoute):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_recent_work_store().unpin, login, payload.route
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/unfiled-drafts")
|
@app.get("/api/v1/unfiled-drafts")
|
||||||
async def get_unfiled_drafts(response: Response):
|
async def get_unfiled_drafts(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
@ -3806,6 +3965,7 @@ async def save_today_recap_and_log_time(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"gitea_time_logged",
|
"gitea_time_logged",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target=f"{repository}#{number}",
|
target=f"{repository}#{number}",
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -3906,6 +4066,7 @@ async def sign_out(request: Request, response: Response):
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"sign_out",
|
"sign_out",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target="current_device",
|
target="current_device",
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -4016,6 +4177,7 @@ async def revoke_active_device(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"device_revoked",
|
"device_revoked",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
device_label=target.device_label,
|
device_label=target.device_label,
|
||||||
target="device",
|
target="device",
|
||||||
)
|
)
|
||||||
|
|
@ -4068,6 +4230,7 @@ async def sign_out_all_devices(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"all_sessions_revoked",
|
"all_sessions_revoked",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target="all_devices",
|
target="all_devices",
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -6641,6 +6804,7 @@ async def _delete_conversation_comment(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"comment_deleted",
|
"comment_deleted",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target=target,
|
target=target,
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -7096,6 +7260,7 @@ async def close_assigned_issue(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"issue_closed",
|
"issue_closed",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target=target,
|
target=target,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -7699,13 +7864,38 @@ async def pull_action_failure(
|
||||||
)
|
)
|
||||||
async def retry_pull_action_job(
|
async def retry_pull_action_job(
|
||||||
retry: PullReadyRequest,
|
retry: PullReadyRequest,
|
||||||
|
request: Request,
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
number: int = PathParam(gt=0),
|
number: int = PathParam(gt=0),
|
||||||
run_id: int = PathParam(gt=0),
|
run_id: int = PathParam(gt=0),
|
||||||
job_index: int = PathParam(ge=0),
|
job_index: int = PathParam(ge=0),
|
||||||
|
step_up_grant: str | None = Header(
|
||||||
|
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||||
|
),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
target = (
|
||||||
|
f"{repository}#{number}@{retry.expected_head_sha}:"
|
||||||
|
f"actions/{run_id}/jobs/{job_index}"
|
||||||
|
)
|
||||||
|
await _require_step_up(
|
||||||
|
request, step_up_grant, action="retry_ci_job", target=target
|
||||||
|
)
|
||||||
|
journal = _security_event_store()
|
||||||
|
try:
|
||||||
|
operation_id = await asyncio.to_thread(
|
||||||
|
journal.reserve,
|
||||||
|
"ci_job_retried",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Security activity is temporarily unavailable. No job was retried."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
async def retry_job():
|
async def retry_job():
|
||||||
if not _has_pull_workspace_access(
|
if not _has_pull_workspace_access(
|
||||||
|
|
@ -7721,14 +7911,26 @@ async def retry_pull_action_job(
|
||||||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
except gitea_proxy.StalePullError:
|
except gitea_proxy.StalePullError:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "New commits arrived. Reload checks before retrying this job."},
|
{"error": "New commits arrived. Reload checks before retrying this job."},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "This check is no longer failed or cannot be retried."},
|
{"error": "This check is no longer failed or cannot be retried."},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
|
|
@ -7740,6 +7942,10 @@ async def retry_pull_action_job(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||||
)
|
)
|
||||||
|
|
@ -8001,6 +8207,7 @@ async def merge_assigned_pull(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"pull_merged",
|
"pull_merged",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target=f"{repository}#{number}",
|
target=f"{repository}#{number}",
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -8092,6 +8299,7 @@ async def delete_merged_source_branch(
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve,
|
journal.reserve,
|
||||||
"source_branch_deleted",
|
"source_branch_deleted",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
@ -8193,14 +8401,39 @@ async def release_action_failure(
|
||||||
"/checks/{run_id}/jobs/{job_index}/retry"
|
"/checks/{run_id}/jobs/{job_index}/retry"
|
||||||
)
|
)
|
||||||
async def retry_release_action_job(
|
async def retry_release_action_job(
|
||||||
|
request: Request,
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
number: int = PathParam(gt=0),
|
number: int = PathParam(gt=0),
|
||||||
commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
|
commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
|
||||||
run_id: int = PathParam(gt=0),
|
run_id: int = PathParam(gt=0),
|
||||||
job_index: int = PathParam(ge=0),
|
job_index: int = PathParam(ge=0),
|
||||||
|
step_up_grant: str | None = Header(
|
||||||
|
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||||
|
),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
target = (
|
||||||
|
f"{repository}#{number}@{commit_sha}:"
|
||||||
|
f"actions/{run_id}/jobs/{job_index}"
|
||||||
|
)
|
||||||
|
await _require_step_up(
|
||||||
|
request, step_up_grant, action="retry_ci_job", target=target
|
||||||
|
)
|
||||||
|
journal = _security_event_store()
|
||||||
|
try:
|
||||||
|
operation_id = await asyncio.to_thread(
|
||||||
|
journal.reserve,
|
||||||
|
"ci_job_retried",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Security activity is temporarily unavailable. No job was retried."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
async def retry_job():
|
async def retry_job():
|
||||||
if not await gitea_proxy.can_recover_merged_release(
|
if not await gitea_proxy.can_recover_merged_release(
|
||||||
|
|
@ -8216,8 +8449,16 @@ async def retry_release_action_job(
|
||||||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "This release check is no longer failed or cannot be retried."},
|
{"error": "This release check is no longer failed or cannot be retried."},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
|
|
@ -8229,6 +8470,10 @@ async def retry_release_action_job(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(journal.finalize, operation_id)
|
||||||
|
except SecurityEventStoreError:
|
||||||
|
pass
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||||
)
|
)
|
||||||
|
|
@ -8279,7 +8524,10 @@ async def prepare_release_rollback(
|
||||||
journal = _security_event_store()
|
journal = _security_event_store()
|
||||||
try:
|
try:
|
||||||
operation_id = await asyncio.to_thread(
|
operation_id = await asyncio.to_thread(
|
||||||
journal.reserve, "release_rollback_prepared", target=target
|
journal.reserve,
|
||||||
|
"release_rollback_prepared",
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
|
target=target,
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -8377,6 +8625,7 @@ async def submit_review(
|
||||||
if submission.decision == "approve"
|
if submission.decision == "approve"
|
||||||
else "pull_review_changes_requested"
|
else "pull_review_changes_requested"
|
||||||
),
|
),
|
||||||
|
principal_id=await _security_principal_id(request),
|
||||||
target=f"{repository}#{number}",
|
target=f"{repository}#{number}",
|
||||||
)
|
)
|
||||||
except SecurityEventStoreError:
|
except SecurityEventStoreError:
|
||||||
|
|
|
||||||
100
src/queue_priority_store.py
Normal file
100
src/queue_priority_store.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Encrypted, revisioned mobile routine queue priority."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_QUEUE_ORDER = (
|
||||||
|
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueuePriorityConflict(ValueError):
|
||||||
|
"""Raised when a stale client attempts to replace the queue order."""
|
||||||
|
|
||||||
|
def __init__(self, snapshot: dict):
|
||||||
|
super().__init__("queue priority changed on another device")
|
||||||
|
self.snapshot = snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class QueuePriorityStore:
|
||||||
|
def __init__(self, path: str | Path, *, timeout: float = 1.0, encryption_key: bytes | None = None):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.timeout = timeout
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||||
|
store="queue-priority",
|
||||||
|
)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS queue_priorities ("
|
||||||
|
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, queue_order TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login(login: str) -> str:
|
||||||
|
normalized = login.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize(order: list[str] | tuple[str, ...]) -> list[str]:
|
||||||
|
if not isinstance(order, (list, tuple)) or len(order) != len(DEFAULT_QUEUE_ORDER):
|
||||||
|
raise ValueError("order must contain every routine queue")
|
||||||
|
if any(not isinstance(name, str) for name in order) or set(order) != set(DEFAULT_QUEUE_ORDER):
|
||||||
|
raise ValueError("order must contain every routine queue exactly once")
|
||||||
|
return list(order)
|
||||||
|
|
||||||
|
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||||
|
if row is None:
|
||||||
|
return {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}, False
|
||||||
|
order, legacy = self._cipher.open(row[1], binding=f"order:{login}")
|
||||||
|
try:
|
||||||
|
normalized = self._normalize(order)
|
||||||
|
except ValueError as error:
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||||
|
return {"revision": int(row[0]), "order": normalized}, legacy
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
snapshot, legacy = self._snapshot(row, login)
|
||||||
|
if row is not None and legacy:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE queue_priorities SET queue_order = ? WHERE login = ? AND queue_order = ?",
|
||||||
|
(self._cipher.seal(snapshot["order"], binding=f"order:{login}"), login, row[1]),
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def replace(self, login: str, expected_revision: int, order: list[str]) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||||
|
raise ValueError("revision is invalid")
|
||||||
|
normalized = self._normalize(order)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
current, _legacy = self._snapshot(row, login)
|
||||||
|
if current["revision"] != expected_revision:
|
||||||
|
raise QueuePriorityConflict(current)
|
||||||
|
revision = expected_revision + 1
|
||||||
|
sealed = self._cipher.seal(normalized, binding=f"order:{login}")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO queue_priorities(login, revision, queue_order) VALUES (?, ?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, queue_order=excluded.queue_order",
|
||||||
|
(login, revision, sealed),
|
||||||
|
)
|
||||||
|
return {"revision": revision, "order": normalized}
|
||||||
178
src/recent_work_store.py
Normal file
178
src/recent_work_store.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Encrypted, account-scoped recent work shared by signed-in devices."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = frozenset({"issue", "filed", "pull", "review", "update"})
|
||||||
|
|
||||||
|
|
||||||
|
class RecentWorkStore:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
encryption_key: bytes | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
pinned_limit: int = 20,
|
||||||
|
):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.timeout = timeout
|
||||||
|
self.limit = max(1, int(limit))
|
||||||
|
self.pinned_limit = max(1, int(pinned_limit))
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||||
|
store="recent-work",
|
||||||
|
)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS recent_work ("
|
||||||
|
"login TEXT PRIMARY KEY, items TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login(login: str) -> str:
|
||||||
|
normalized = login.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize(item: dict) -> dict:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
kind = item.get("kind")
|
||||||
|
number = item.get("number")
|
||||||
|
title = item.get("title")
|
||||||
|
repository = item.get("repository", "")
|
||||||
|
if (
|
||||||
|
kind not in KINDS
|
||||||
|
or not isinstance(number, int)
|
||||||
|
or isinstance(number, bool)
|
||||||
|
or number < 1
|
||||||
|
or not isinstance(title, str)
|
||||||
|
or not title.strip()
|
||||||
|
):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
title = title.strip()[:180]
|
||||||
|
if kind == "update":
|
||||||
|
if repository:
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
route = f"#/my-work/update/{number}"
|
||||||
|
normalized = {"kind": kind, "number": number, "title": title, "route": route}
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
not isinstance(repository, str)
|
||||||
|
or repository.count("/") != 1
|
||||||
|
or any(not part or not all(character.isalnum() or character in "_.-" for character in part)
|
||||||
|
for part in repository.split("/"))
|
||||||
|
):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
route = f"#/my-work/{kind}/{repository}/{number}"
|
||||||
|
normalized = {
|
||||||
|
"kind": kind,
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"title": title,
|
||||||
|
"route": route,
|
||||||
|
}
|
||||||
|
if item.get("route", route) != route:
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _state(self, row, login: str) -> tuple[dict, bool]:
|
||||||
|
if row is None:
|
||||||
|
return {"items": [], "pinned": []}, False
|
||||||
|
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
|
||||||
|
if isinstance(payload, list):
|
||||||
|
payload = {"items": payload, "pinned": []}
|
||||||
|
legacy = True
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list) or not isinstance(payload.get("pinned"), list):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
try:
|
||||||
|
items = [self._normalize(item) for item in payload["items"]][: self.limit]
|
||||||
|
pinned = [self._normalize(item) for item in payload["pinned"]][: self.pinned_limit]
|
||||||
|
if len({item["route"] for item in pinned}) != len(pinned):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
return {"items": items, "pinned": pinned}, legacy
|
||||||
|
except ValueError as error:
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||||
|
|
||||||
|
def _seal(self, state: dict, login: str) -> str:
|
||||||
|
return self._cipher.seal(state, binding=f"items:{login}")
|
||||||
|
|
||||||
|
def _write(self, connection: sqlite3.Connection, login: str, state: dict) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
|
||||||
|
(login, self._seal(state, login)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
state, legacy = self._state(row, login)
|
||||||
|
if row is not None and legacy:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
|
||||||
|
(self._seal(state, login), login, row[0]),
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def record(self, login: str, item: dict) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
normalized = self._normalize(item)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
state, _legacy = self._state(row, login)
|
||||||
|
state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
|
||||||
|
state["pinned"] = [
|
||||||
|
normalized,
|
||||||
|
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||||
|
] if any(entry["route"] == normalized["route"] for entry in state["pinned"]) else state["pinned"]
|
||||||
|
self._write(connection, login, state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def pin(self, login: str, item: dict) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
normalized = self._normalize(item)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
state, _legacy = self._state(row, login)
|
||||||
|
state["pinned"] = [
|
||||||
|
normalized,
|
||||||
|
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||||
|
][: self.pinned_limit]
|
||||||
|
self._write(connection, login, state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def unpin(self, login: str, route: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
if not isinstance(route, str) or not route:
|
||||||
|
raise ValueError("recent work route is invalid")
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
state, _legacy = self._state(row, login)
|
||||||
|
state["pinned"] = [entry for entry in state["pinned"] if entry["route"] != route]
|
||||||
|
self._write(connection, login, state)
|
||||||
|
return state
|
||||||
|
|
@ -76,6 +76,7 @@ class SecurityEventStore:
|
||||||
CREATE TABLE IF NOT EXISTS security_events (
|
CREATE TABLE IF NOT EXISTS security_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
|
principal_id INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'completed',
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
operation_id TEXT
|
operation_id TEXT
|
||||||
|
|
@ -93,6 +94,10 @@ class SecurityEventStore:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||||
)
|
)
|
||||||
|
if "principal_id" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE security_events ADD COLUMN principal_id INTEGER"
|
||||||
|
)
|
||||||
plaintext_columns = {"kind", "method", "device_label", "target"}
|
plaintext_columns = {"kind", "method", "device_label", "target"}
|
||||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||||
self._migrate_plaintext(
|
self._migrate_plaintext(
|
||||||
|
|
@ -102,6 +107,10 @@ class SecurityEventStore:
|
||||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||||
"ON security_events(created_at DESC, id DESC)"
|
"ON security_events(created_at DESC, id DESC)"
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS security_events_principal "
|
||||||
|
"ON security_events(principal_id, id DESC)"
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||||
|
|
@ -126,6 +135,7 @@ class SecurityEventStore:
|
||||||
CREATE TABLE security_events_encrypted (
|
CREATE TABLE security_events_encrypted (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
|
principal_id INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'completed',
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
operation_id TEXT
|
operation_id TEXT
|
||||||
|
|
@ -145,7 +155,8 @@ class SecurityEventStore:
|
||||||
) in rows:
|
) in rows:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO security_events_encrypted "
|
"INSERT INTO security_events_encrypted "
|
||||||
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
|
"(id, payload, principal_id, created_at, status, operation_id) "
|
||||||
|
"VALUES (?, ?, NULL, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
event_id,
|
event_id,
|
||||||
payload
|
payload
|
||||||
|
|
@ -195,6 +206,7 @@ class SecurityEventStore:
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
*,
|
*,
|
||||||
|
principal_id: int,
|
||||||
method: str | None = None,
|
method: str | None = None,
|
||||||
device_label: str | None = None,
|
device_label: str | None = None,
|
||||||
target: str | None = None,
|
target: str | None = None,
|
||||||
|
|
@ -203,8 +215,9 @@ class SecurityEventStore:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
"INSERT INTO security_events(principal_id, created_at, status) "
|
||||||
(now,),
|
"VALUES (?, ?, 'completed')",
|
||||||
|
(principal_id, now),
|
||||||
)
|
)
|
||||||
event_id = cursor.lastrowid
|
event_id = cursor.lastrowid
|
||||||
payload = self._seal_event(
|
payload = self._seal_event(
|
||||||
|
|
@ -224,6 +237,7 @@ class SecurityEventStore:
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
*,
|
*,
|
||||||
|
principal_id: int,
|
||||||
method: str | None = None,
|
method: str | None = None,
|
||||||
device_label: str | None = None,
|
device_label: str | None = None,
|
||||||
target: str | None = None,
|
target: str | None = None,
|
||||||
|
|
@ -233,9 +247,9 @@ class SecurityEventStore:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
|
||||||
"VALUES (?, 'pending', ?)",
|
"VALUES (?, ?, 'pending', ?)",
|
||||||
(now, operation_id),
|
(principal_id, now, operation_id),
|
||||||
)
|
)
|
||||||
event_id = cursor.lastrowid
|
event_id = cursor.lastrowid
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|
@ -282,12 +296,14 @@ class SecurityEventStore:
|
||||||
"Security activity is temporarily unavailable"
|
"Security activity is temporarily unavailable"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
def list(
|
||||||
|
self, *, principal_id: int, limit: int = 50, cursor: int | None = None
|
||||||
|
) -> SecurityEventPage:
|
||||||
bounded_limit = min(100, max(1, limit))
|
bounded_limit = min(100, max(1, limit))
|
||||||
parameters: list[int] = []
|
parameters: list[int] = [principal_id]
|
||||||
where = ""
|
where = "WHERE principal_id = ?"
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
where = "WHERE id < ?"
|
where += " AND id < ?"
|
||||||
parameters.append(cursor)
|
parameters.append(cursor)
|
||||||
parameters.append(bounded_limit + 1)
|
parameters.append(bounded_limit + 1)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||||
|
|
@ -10,4 +12,15 @@ os.environ.setdefault(
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||||
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def stable_upstream_identity(monkeypatch):
|
||||||
|
"""Keep API tests off the network when security ownership resolves identity."""
|
||||||
|
from src import main
|
||||||
|
|
||||||
|
async def current_user():
|
||||||
|
return {"id": 42, "login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", current_user)
|
||||||
|
|
@ -215,7 +215,7 @@ def test_operator_reorders_routine_queues_without_leaking_priority_between_accou
|
||||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||||
) == ["following", "attention", "authored"]
|
) == ["following", "attention", "authored"]
|
||||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Following moved earlier.")
|
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
|
||||||
controls = page.locator(".mobile-queue-priority-controls button")
|
controls = page.locator(".mobile-queue-priority-controls button")
|
||||||
assert controls.count() == 18
|
assert controls.count() == 18
|
||||||
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||||
|
|
@ -85,6 +85,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
expect(page.locator("#human-gates")).to_be_visible()
|
||||||
|
|
@ -126,6 +127,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||||
popup.close()
|
popup.close()
|
||||||
|
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
expect(page.locator("#human-gates")).to_be_visible()
|
||||||
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
|
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
|
||||||
|
|
@ -147,6 +150,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||||
assert len(decision_requests) == 1
|
assert len(decision_requests) == 1
|
||||||
|
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
expect(page.locator("#human-gates")).to_be_visible()
|
||||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||||
|
|
@ -244,9 +249,13 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
expect(page.locator("#human-gates")).to_be_visible()
|
||||||
cached_before = page.evaluate("Object.keys(localStorage).sort()")
|
cached_before = page.evaluate(
|
||||||
|
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
|
||||||
|
)
|
||||||
|
|
||||||
page.locator("#human-gates-history").click()
|
page.locator("#human-gates-history").click()
|
||||||
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
|
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
|
||||||
|
|
@ -264,7 +273,9 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
|
||||||
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
||||||
expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
|
expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
|
||||||
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
|
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
|
||||||
assert page.evaluate("Object.keys(localStorage).sort()") == cached_before
|
assert page.evaluate(
|
||||||
|
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
|
||||||
|
) == cached_before
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
for selector in ("#human-gates-pending", "#human-gates-history"):
|
for selector in ("#human-gates-pending", "#human-gates-history"):
|
||||||
bounds = page.locator(selector).bounding_box()
|
bounds = page.locator(selector).bounding_box()
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
page.locator('[data-mobile-task="work"]').click()
|
||||||
|
|
@ -56,6 +60,7 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||||
expect(sheet).to_be_hidden()
|
expect(sheet).to_be_hidden()
|
||||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||||
page.locator("#close-find-work").click()
|
page.locator("#close-find-work").click()
|
||||||
|
expect(page.locator("#find-work-sheet")).to_be_hidden()
|
||||||
|
|
||||||
context.set_offline(True)
|
context.set_offline(True)
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
page.locator('[data-mobile-task="work"]').click()
|
||||||
|
|
@ -66,8 +71,9 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||||
|
|
||||||
page.locator("#close-mobile-first-task").click()
|
page.locator("#close-mobile-first-task").click()
|
||||||
|
expect(sheet).to_be_hidden()
|
||||||
context.set_offline(False)
|
context.set_offline(False)
|
||||||
page.evaluate(
|
probe_result = page.evaluate(
|
||||||
"""() => {
|
"""() => {
|
||||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
||||||
|
|
@ -78,17 +84,54 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||||
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||||
coach: isolatedCoach,
|
coach: isolatedCoach,
|
||||||
});
|
});
|
||||||
return window.firstTaskOutcomeProbe.refresh();
|
return {
|
||||||
|
result: window.firstTaskOutcomeProbe.refresh(),
|
||||||
|
state: localStorage.getItem('stackchain.first-task.v1:timmy'),
|
||||||
|
mobile: matchMedia('(max-width: 600px)').matches,
|
||||||
|
};
|
||||||
}"""
|
}"""
|
||||||
)
|
)
|
||||||
|
assert probe_result == {"result": "coaching", "state": "coaching", "mobile": True}
|
||||||
coach = page.locator("[data-mobile-first-task-coach]")
|
coach = page.locator("[data-mobile-first-task-coach]")
|
||||||
expect(coach).to_be_visible()
|
try:
|
||||||
|
expect(coach).to_be_visible()
|
||||||
|
except AssertionError as error:
|
||||||
|
raise AssertionError(page.evaluate("""() => {
|
||||||
|
const coach = document.querySelector('[data-mobile-first-task-coach]');
|
||||||
|
const hud = coach.closest('[data-mobile-today-hud]');
|
||||||
|
return {
|
||||||
|
coachHidden: coach.hidden,
|
||||||
|
coachDisplay: getComputedStyle(coach).display,
|
||||||
|
hudHidden: hud.hidden,
|
||||||
|
hudOverlay: hud.getAttribute('data-overlay-hidden'),
|
||||||
|
hudDisplay: getComputedStyle(hud).display,
|
||||||
|
open: Array.from(document.querySelectorAll('dialog, .open'))
|
||||||
|
.filter(element => element.open || element.classList.contains('open'))
|
||||||
|
.map(element => element.id || element.className),
|
||||||
|
};
|
||||||
|
}""")) from error
|
||||||
expect(coach).to_contain_text("Complete your first task")
|
expect(coach).to_contain_text("Complete your first task")
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
completion = page.evaluate("""() => {
|
||||||
|
const completed = window.firstTaskOutcomeProbe.completeOutcome();
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
status: document.querySelector('#today-sync-status').textContent,
|
||||||
|
pending: Object.keys(localStorage).filter(key =>
|
||||||
|
key.startsWith('stackchain.today-sync.v1.timmy.operation.')),
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
assert completion["completed"] is True
|
||||||
|
assert completion["pending"]
|
||||||
|
assert "sync pending" in completion["status"]
|
||||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||||
expect(coach).to_be_hidden()
|
expect(coach).to_be_hidden()
|
||||||
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
||||||
|
remote_activation = page.evaluate("""async () => {
|
||||||
|
const response = await fetch('api/v1/today');
|
||||||
|
return (await response.json()).first_task_state;
|
||||||
|
}""")
|
||||||
|
assert remote_activation == "complete"
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
fresh_context = browser.new_context(
|
fresh_context = browser.new_context(
|
||||||
|
|
@ -100,7 +143,12 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||||
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
fresh_page.locator("#submit-sign-in").click()
|
fresh_page.locator("#submit-sign-in").click()
|
||||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
fresh_page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
fresh_page.wait_for_load_state("networkidle")
|
||||||
assert fresh_page.evaluate(
|
assert fresh_page.evaluate(
|
||||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||||
) == "complete"
|
) == "complete"
|
||||||
|
|
@ -128,6 +176,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
browser_errors: list[str] = []
|
browser_errors: list[str] = []
|
||||||
failed_responses: list[str] = []
|
failed_responses: list[str] = []
|
||||||
workspace_requests: list[str] = []
|
workspace_requests: list[str] = []
|
||||||
|
optional_workspace_requests: list[str] = []
|
||||||
launch_transfer_events: list[str] = []
|
launch_transfer_events: list[str] = []
|
||||||
live_requests: list[str] = []
|
live_requests: list[str] = []
|
||||||
|
|
||||||
|
|
@ -171,6 +220,12 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
)
|
)
|
||||||
if "feature-work-core-" in request.url else None,
|
if "feature-work-core-" in request.url else None,
|
||||||
)
|
)
|
||||||
|
page.on(
|
||||||
|
"request",
|
||||||
|
lambda request: optional_workspace_requests.append(request.url)
|
||||||
|
if "feature-today-timer-" in request.url or "feature-planning-" in request.url
|
||||||
|
else None,
|
||||||
|
)
|
||||||
page.on(
|
page.on(
|
||||||
"requestfinished",
|
"requestfinished",
|
||||||
lambda request: launch_transfer_events.append("core-finished")
|
lambda request: launch_transfer_events.append("core-finished")
|
||||||
|
|
@ -184,6 +239,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
)
|
)
|
||||||
|
|
||||||
page.goto(origin + "/", wait_until="networkidle")
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
assert optional_workspace_requests == []
|
||||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
live_requests.clear()
|
live_requests.clear()
|
||||||
|
|
@ -194,9 +250,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
), launch_transfer_events
|
), launch_transfer_events
|
||||||
|
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
assert optional_workspace_requests == []
|
||||||
revisionless_live_requests = [url for url in live_requests if "?" not in url]
|
revisionless_live_requests = [url for url in live_requests if "?" not in url]
|
||||||
assert len(revisionless_live_requests) == 1, live_requests
|
assert len(revisionless_live_requests) == 1, live_requests
|
||||||
initial_live_requests = len(live_requests)
|
initial_live_requests = len(live_requests)
|
||||||
|
|
||||||
|
page.locator("#work-settings-toggle").click()
|
||||||
|
tomorrow = page.locator("#plan-tomorrow")
|
||||||
|
expect(tomorrow).to_be_visible()
|
||||||
|
assert len(optional_workspace_requests) == 2, optional_workspace_requests
|
||||||
|
tomorrow_bounds = tomorrow.bounding_box()
|
||||||
|
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||||
|
tomorrow.click()
|
||||||
|
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||||
|
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||||
|
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
page.locator("#cancel-plan-today").click()
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -226,18 +297,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
bounds = control.bounding_box()
|
bounds = control.bounding_box()
|
||||||
assert bounds and bounds["height"] >= 44
|
assert bounds and bounds["height"] >= 44
|
||||||
|
|
||||||
page.locator("#work-settings-toggle").click()
|
|
||||||
tomorrow = page.locator("#plan-tomorrow")
|
|
||||||
expect(tomorrow).to_be_visible()
|
|
||||||
tomorrow_bounds = tomorrow.bounding_box()
|
|
||||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
|
||||||
tomorrow.click()
|
|
||||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
|
||||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
|
||||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
|
||||||
page.locator("#cancel-plan-today").click()
|
|
||||||
|
|
||||||
page.locator('[data-mobile-task="queues"]').click()
|
page.locator('[data-mobile-task="queues"]').click()
|
||||||
page.locator(".mobile-queue-all summary").click()
|
page.locator(".mobile-queue-all summary").click()
|
||||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||||
|
|
@ -346,7 +405,10 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
|
||||||
try:
|
try:
|
||||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
context = browser.new_context(
|
||||||
|
viewport={"width": 390, "height": 844}, service_workers="block"
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
page.goto(origin + "/", wait_until="networkidle")
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
|
||||||
def interrupt_once(route):
|
def interrupt_once(route):
|
||||||
|
|
@ -361,6 +423,7 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('[data-mobile-task="find"]').click()
|
||||||
|
|
||||||
page.wait_for_timeout(1500)
|
page.wait_for_timeout(1500)
|
||||||
resources = page.evaluate(
|
resources = page.evaluate(
|
||||||
|
|
@ -406,6 +469,10 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
|
|
@ -508,6 +575,10 @@ def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
|
|
||||||
page.locator('[data-mobile-task="queues"]').click()
|
page.locator('[data-mobile-task="queues"]').click()
|
||||||
page.locator('[data-mobile-queue="agenda"]').click()
|
page.locator('[data-mobile-queue="agenda"]').click()
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||||
process.communicate()
|
process.communicate()
|
||||||
|
|
||||||
|
|
||||||
|
def hydrate_workspace(page) -> None:
|
||||||
|
page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def indexed_issue_records(page: Page) -> list[dict]:
|
def indexed_issue_records(page: Page) -> list[dict]:
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
"""async () => {
|
"""async () => {
|
||||||
|
|
@ -275,6 +282,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||||
assert fake.created_issues == []
|
assert fake.created_issues == []
|
||||||
|
|
||||||
page.reload(wait_until="domcontentloaded")
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||||
durable_after_reload = indexed_issue_records(page)
|
durable_after_reload = indexed_issue_records(page)
|
||||||
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
||||||
|
|
@ -322,6 +330,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||||
page.wait_for_timeout(100)
|
page.wait_for_timeout(100)
|
||||||
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
|
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
|
||||||
|
|
@ -60,6 +60,7 @@ def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work
|
||||||
})"""
|
})"""
|
||||||
)
|
)
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
stored = page.evaluate(
|
stored = page.evaluate(
|
||||||
"""() => new Promise((resolve,reject)=>{
|
"""() => new Promise((resolve,reject)=>{
|
||||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||||
|
|
|
||||||
107
tests/e2e/test_mobile_pinned_work_release.py
Normal file
107
tests/e2e/test_mobile_pinned_work_release.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||||
|
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
|
||||||
|
pytest.importorskip("playwright.sync_api")
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
FRONTEND = ROOT / "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("viewport", [
|
||||||
|
{"width": 320, "height": 568},
|
||||||
|
{"width": 390, "height": 844},
|
||||||
|
])
|
||||||
|
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport=viewport)
|
||||||
|
page.set_content((FRONTEND / "index.html").read_text())
|
||||||
|
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||||
|
page.add_script_tag(path=FRONTEND / "mobile-recent-work.js")
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {
|
||||||
|
getItem:key => values.get(key) || null,
|
||||||
|
setItem:(key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
window.opened = [];
|
||||||
|
window.recentWork = createMobileRecentWork({
|
||||||
|
storage, getLogin:() => 'timmy', document,
|
||||||
|
section:document.querySelector('#mobile-recent-work'),
|
||||||
|
list:document.querySelector('#mobile-recent-work-list'),
|
||||||
|
pinnedSection:document.querySelector('#mobile-pinned-work'),
|
||||||
|
pinnedList:document.querySelector('#mobile-pinned-work-list'),
|
||||||
|
pinnedToggle:document.querySelector('#mobile-pinned-work-toggle'),
|
||||||
|
detailPins:document.querySelectorAll('[data-current-work-pin]'),
|
||||||
|
status:document.querySelector('#mobile-recent-work-status'),
|
||||||
|
openRoute:route => window.opened.push(route),
|
||||||
|
});
|
||||||
|
for (let number=1; number<=20; number += 1) {
|
||||||
|
const item = {
|
||||||
|
kind:'issue', repository:'stackchain/stackchain-dashboard', number,
|
||||||
|
title:'Pinned mobile work ' + number,
|
||||||
|
};
|
||||||
|
window.recentWork.record(item);
|
||||||
|
window.recentWork.pin(item);
|
||||||
|
}
|
||||||
|
document.querySelector('#mobile-queue-sheet').showModal();
|
||||||
|
}""")
|
||||||
|
|
||||||
|
expect(page.locator("#mobile-pinned-work")).to_be_visible()
|
||||||
|
expect(page.locator("#mobile-recent-work")).to_be_hidden()
|
||||||
|
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
|
||||||
|
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
|
||||||
|
toggle = page.locator("#mobile-pinned-work-toggle")
|
||||||
|
open_button = page.get_by_role(
|
||||||
|
"button", name="Open Pinned mobile work 20, issue stackchain/stackchain-dashboard #20"
|
||||||
|
).first
|
||||||
|
pin_button = page.get_by_role(
|
||||||
|
"button", name="Unpin Pinned mobile work 20"
|
||||||
|
)
|
||||||
|
for control in (open_button, pin_button, toggle):
|
||||||
|
bounds = control.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
|
toggle.click()
|
||||||
|
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(20)
|
||||||
|
expect(toggle).to_have_text("Show fewer")
|
||||||
|
assert toggle.get_attribute("aria-expanded") == "true"
|
||||||
|
toggle.click()
|
||||||
|
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
|
||||||
|
|
||||||
|
open_button.focus()
|
||||||
|
open_button.press("Enter")
|
||||||
|
assert page.evaluate("window.opened") == [
|
||||||
|
"#/my-work/issue/stackchain/stackchain-dashboard/20"
|
||||||
|
]
|
||||||
|
|
||||||
|
page.evaluate("""() => {
|
||||||
|
document.querySelector('#mobile-queue-sheet').close();
|
||||||
|
document.querySelector('#issue-sheet').classList.add('open');
|
||||||
|
window.recentWork.setCurrent({
|
||||||
|
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1489,
|
||||||
|
title:'Pin the current item from mobile detail',
|
||||||
|
});
|
||||||
|
}""")
|
||||||
|
detail_pin = page.get_by_role(
|
||||||
|
"button", name="Pin Pin the current item from mobile detail"
|
||||||
|
)
|
||||||
|
expect(detail_pin).to_be_visible()
|
||||||
|
bounds = detail_pin.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
detail_pin.focus()
|
||||||
|
detail_pin.press("Enter")
|
||||||
|
expect(detail_pin).to_have_text("Pinned")
|
||||||
|
expect(detail_pin).to_have_attribute("aria-pressed", "true")
|
||||||
|
assert page.evaluate("window.recentWork.pinned()[0].number") == 1489
|
||||||
|
browser.close()
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
def open_today_action(page, selector: str):
|
def open_today_action(page, selector: str):
|
||||||
|
|
@ -52,6 +52,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
@ -269,6 +270,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
localStorage.setItem(prefix + 'broken', '{not-json');
|
localStorage.setItem(prefix + 'broken', '{not-json');
|
||||||
}""")
|
}""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
expect(page.locator("#today-sync-status")).to_have_text(
|
expect(page.locator("#today-sync-status")).to_have_text(
|
||||||
"Today queue recovered · discarded 1 unreadable device record."
|
"Today queue recovered · discarded 1 unreadable device record."
|
||||||
|
|
@ -276,7 +278,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
assert page.evaluate("""() => !Object.keys(localStorage).some(
|
assert page.evaluate("""() => !Object.keys(localStorage).some(
|
||||||
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
|
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
|
||||||
)""")
|
)""")
|
||||||
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
|
try:
|
||||||
|
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
|
||||||
|
except AssertionError as error:
|
||||||
|
raise AssertionError(page.evaluate("""() => ({
|
||||||
|
login: document.querySelector('#current-user')?.textContent,
|
||||||
|
timers: Object.fromEntries(Object.keys(localStorage)
|
||||||
|
.filter(key => key.startsWith('stackchain.today-timer.v1.'))
|
||||||
|
.map(key => [key, JSON.parse(localStorage.getItem(key))])),
|
||||||
|
status: document.querySelector('#today-break-status').outerHTML,
|
||||||
|
})""")) from error
|
||||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||||
page.locator("#close-issue-sheet").click()
|
page.locator("#close-issue-sheet").click()
|
||||||
if page.locator("#plan-today-sheet").is_visible():
|
if page.locator("#plan-today-sheet").is_visible():
|
||||||
|
|
@ -377,6 +388,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
page.locator('[data-mobile-task="work"]').click()
|
||||||
|
|
@ -446,6 +458,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
|
||||||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
|
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
|
||||||
}""")
|
}""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||||
expect(restored_pause).to_be_visible()
|
expect(restored_pause).to_be_visible()
|
||||||
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
|
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||||
|
|
@ -512,6 +525,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
page.locator('[data-mobile-task="work"]').click()
|
||||||
|
|
@ -562,6 +576,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
||||||
}));
|
}));
|
||||||
}""")
|
}""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||||
page.locator("#close-issue-sheet").click()
|
page.locator("#close-issue-sheet").click()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||||
|
|
@ -50,6 +50,7 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||||
|
|
@ -47,6 +47,7 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||||
|
|
@ -71,6 +71,7 @@ def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
def stage_phone_plan():
|
def stage_phone_plan():
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
from fake_gitea import FakeGiteaServer
|
||||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||||
|
|
||||||
|
|
||||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||||
|
|
@ -36,6 +36,7 @@ def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path)
|
||||||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||||
]))""")
|
]))""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
dialog = page.locator("#today-handoff-dialog")
|
dialog = page.locator("#today-handoff-dialog")
|
||||||
expect(dialog).to_be_visible()
|
expect(dialog).to_be_visible()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from src import main
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
|
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||||
|
|
||||||
async def user():
|
async def user():
|
||||||
return {"id": 1, "login": "timmy"}
|
return {"id": 1, "login": "timmy"}
|
||||||
|
|
||||||
|
|
@ -15,13 +17,17 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
|
||||||
async def empty_events(user_data=None):
|
async def empty_events(user_data=None):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def empty_live_snapshot(**_revisions):
|
||||||
|
return main.JSONResponse({})
|
||||||
|
|
||||||
monkeypatch.setattr(main, "current_user", user)
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
monkeypatch.setattr(main, "repos", empty_collection)
|
monkeypatch.setattr(main, "repos", empty_collection)
|
||||||
monkeypatch.setattr(main, "issues", empty_collection)
|
monkeypatch.setattr(main, "issues", empty_collection)
|
||||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||||
monkeypatch.setattr(main, "activity_events", empty_events)
|
monkeypatch.setattr(main, "activity_events", empty_events)
|
||||||
|
monkeypatch.setattr(main, "_live_snapshot_response", empty_live_snapshot)
|
||||||
|
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
||||||
response = await client.get(path)
|
response = await client.get(path)
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,32 @@ const poller = createContextPoller({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_can_force_a_full_identity_refresh_after_adoption():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const requested = [];
|
||||||
|
const seed = {{
|
||||||
|
context:{{user:{{login:'timmy'}}}}, events:[], notifications:[],
|
||||||
|
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2'}},
|
||||||
|
}};
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: revisions => {{
|
||||||
|
requested.push({{...revisions}});
|
||||||
|
return Promise.resolve({{...seed, freshness:{{sections:{{context:{{degraded:false}}}}}}}});
|
||||||
|
}},
|
||||||
|
onSnapshot: () => {{}}, onError: error => {{ throw error; }},
|
||||||
|
setTimer: () => 1, clearTimer: () => {{}},
|
||||||
|
}});
|
||||||
|
poller.adopt(seed);
|
||||||
|
(async () => {{
|
||||||
|
await poller.refresh({{force:true, full:true}});
|
||||||
|
process.stdout.write(JSON.stringify({{requested}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {"requested": [{}]}
|
||||||
|
|
||||||
|
|
||||||
def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh():
|
def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
|
@ -200,6 +226,9 @@ def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load()
|
||||||
assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source
|
assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source
|
||||||
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
|
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
|
||||||
assert "if (!adoptedProgressiveSnapshot) await load();" in source
|
assert "if (!adoptedProgressiveSnapshot) await load();" in source
|
||||||
|
assert "contextPoller.refresh({ force:true, full:true })" in source
|
||||||
|
assert "fetchReviewJson('api/v1/background-identity')" in source
|
||||||
|
assert "initialAccountRecovery = timerView.restore(todaySync.flush());" in source
|
||||||
|
|
||||||
|
|
||||||
def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():
|
def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ def test_release_rollback_is_a_supported_step_up_action():
|
||||||
assert "prepare_release_rollback" in get_args(main.StepUpAction)
|
assert "prepare_release_rollback" in get_args(main.StepUpAction)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_job_retry_is_a_supported_step_up_action():
|
||||||
|
assert "retry_ci_job" in get_args(main.StepUpAction)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def access_control(monkeypatch, tmp_path):
|
def access_control(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
|
@ -125,6 +129,158 @@ async def fresh_grant(client, action: str, target: str) -> str:
|
||||||
return response.json()["grant"]
|
return response.json()["grant"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ci_job_reruns_require_exact_one_time_authorization_and_audit_both_flows(
|
||||||
|
access_control, monkeypatch
|
||||||
|
):
|
||||||
|
lifecycle = []
|
||||||
|
|
||||||
|
class Journal:
|
||||||
|
def record(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reserve(self, kind, *, principal_id, target):
|
||||||
|
assert principal_id == 42
|
||||||
|
operation_id = f"operation-{len(lifecycle)}"
|
||||||
|
lifecycle.append(("reserve", kind, target, operation_id))
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
def finalize(self, operation_id):
|
||||||
|
lifecycle.append(("finalize", operation_id))
|
||||||
|
|
||||||
|
def discard(self, operation_id):
|
||||||
|
lifecycle.append(("discard", operation_id))
|
||||||
|
|
||||||
|
async def capabilities(repository, number):
|
||||||
|
return {"authored": True, "assigned": False}
|
||||||
|
|
||||||
|
async def retry_pull(repository, number, head_sha, run_id, job_index):
|
||||||
|
lifecycle.append(("retry-pull", repository, number, head_sha, run_id, job_index))
|
||||||
|
return {"status": "queued"}
|
||||||
|
|
||||||
|
async def release_access(repository, number, commit_sha):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def retry_release(repository, commit_sha, run_id, job_index):
|
||||||
|
lifecycle.append(("retry-release", repository, commit_sha, run_id, job_index))
|
||||||
|
return {"status": "queued"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
|
||||||
|
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry_pull)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry_release)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
pull_path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
|
||||||
|
release_path = (
|
||||||
|
"/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234"
|
||||||
|
"/checks/91/jobs/3/retry"
|
||||||
|
)
|
||||||
|
pull_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
|
||||||
|
release_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
signed_in = await client.post(
|
||||||
|
"/api/v1/session",
|
||||||
|
json={"access_token": "correct horse battery staple"},
|
||||||
|
)
|
||||||
|
assert signed_in.status_code == 200
|
||||||
|
headers = {
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = await client.post(
|
||||||
|
pull_path, json={"expected_head_sha": "abc1234"}, headers=headers
|
||||||
|
)
|
||||||
|
wrong_grant = await fresh_grant(
|
||||||
|
client, "retry_ci_job", "stackchain/api#7@different:actions/91/jobs/3"
|
||||||
|
)
|
||||||
|
mismatched = await client.post(
|
||||||
|
pull_path,
|
||||||
|
json={"expected_head_sha": "abc1234"},
|
||||||
|
headers={**headers, "X-Step-Up-Grant": wrong_grant},
|
||||||
|
)
|
||||||
|
pull_grant = await fresh_grant(client, "retry_ci_job", pull_target)
|
||||||
|
retried_pull = await client.post(
|
||||||
|
pull_path,
|
||||||
|
json={"expected_head_sha": "abc1234"},
|
||||||
|
headers={**headers, "X-Step-Up-Grant": pull_grant},
|
||||||
|
)
|
||||||
|
replayed = await client.post(
|
||||||
|
pull_path,
|
||||||
|
json={"expected_head_sha": "abc1234"},
|
||||||
|
headers={**headers, "X-Step-Up-Grant": pull_grant},
|
||||||
|
)
|
||||||
|
release_grant = await fresh_grant(client, "retry_ci_job", release_target)
|
||||||
|
retried_release = await client.post(
|
||||||
|
release_path,
|
||||||
|
headers={**headers, "X-Step-Up-Grant": release_grant},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert missing.status_code == 428
|
||||||
|
assert missing.json()["detail"] == {
|
||||||
|
"detail": "Fresh authorization required",
|
||||||
|
"code": "step_up_required",
|
||||||
|
"action": "retry_ci_job",
|
||||||
|
"target": pull_target,
|
||||||
|
}
|
||||||
|
assert mismatched.status_code == 428
|
||||||
|
assert retried_pull.status_code == 202
|
||||||
|
assert replayed.status_code == 428
|
||||||
|
assert retried_release.status_code == 202
|
||||||
|
assert lifecycle == [
|
||||||
|
("reserve", "ci_job_retried", pull_target, "operation-0"),
|
||||||
|
("retry-pull", "stackchain/api", 7, "abc1234", 91, 3),
|
||||||
|
("finalize", "operation-0"),
|
||||||
|
("reserve", "ci_job_retried", release_target, "operation-3"),
|
||||||
|
("retry-release", "stackchain/api", "abc1234", 91, 3),
|
||||||
|
("finalize", "operation-3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ci_job_retry_fails_closed_when_security_activity_is_unavailable(
|
||||||
|
access_control, monkeypatch
|
||||||
|
):
|
||||||
|
retry_calls = []
|
||||||
|
|
||||||
|
class UnavailableJournal:
|
||||||
|
def reserve(self, *_args, **_kwargs):
|
||||||
|
from src.security_event_store import SecurityEventStoreError
|
||||||
|
|
||||||
|
raise SecurityEventStoreError("unavailable")
|
||||||
|
|
||||||
|
async def retry(*args):
|
||||||
|
retry_calls.append(args)
|
||||||
|
return {"status": "queued"}
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
|
||||||
|
target = "stackchain/api#7@abc1234:actions/91/jobs/3"
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
grant = await fresh_grant(client, "retry_ci_job", target)
|
||||||
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry)
|
||||||
|
response = await client.post(
|
||||||
|
path,
|
||||||
|
json={"expected_head_sha": "abc1234"},
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
"X-Step-Up-Grant": grant,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {
|
||||||
|
"error": "Security activity is temporarily unavailable. No job was retried."
|
||||||
|
}
|
||||||
|
assert retry_calls == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
|
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
|
||||||
access_control, monkeypatch
|
access_control, monkeypatch
|
||||||
|
|
@ -135,7 +291,8 @@ async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization
|
||||||
def record(self, *_args, **_kwargs):
|
def record(self, *_args, **_kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
|
assert principal_id == 42
|
||||||
lifecycle.append(("reserve", kind, target))
|
lifecycle.append(("reserve", kind, target))
|
||||||
return "cleanup-operation"
|
return "cleanup-operation"
|
||||||
|
|
||||||
|
|
@ -415,7 +572,7 @@ async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
events = main._security_event_store().list(limit=10).events
|
events = main._security_event_store().list(principal_id=42, limit=10).events
|
||||||
assert denied.status_code == 401
|
assert denied.status_code == 401
|
||||||
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||||
|
|
@ -470,7 +627,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
events = main._security_event_store().list(limit=10).events
|
events = main._security_event_store().list(principal_id=42, limit=10).events
|
||||||
assert options.status_code == 200
|
assert options.status_code == 200
|
||||||
assert denied.status_code == 401
|
assert denied.status_code == 401
|
||||||
assert "grant" not in denied.json()
|
assert "grant" not in denied.json()
|
||||||
|
|
|
||||||
19
tests/test_disk_capacity.py
Normal file
19
tests/test_disk_capacity.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from src.disk_capacity import assess_disk_capacity, read_disk_capacity
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_at_incident_threshold_requires_action():
|
||||||
|
status = assess_disk_capacity(total_bytes=100, available_bytes=15)
|
||||||
|
|
||||||
|
assert status == {
|
||||||
|
"usage_percent": 85.0,
|
||||||
|
"threshold_percent": 85.0,
|
||||||
|
"incident": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_disk_capacity_assesses_a_real_filesystem(tmp_path):
|
||||||
|
status = read_disk_capacity(tmp_path)
|
||||||
|
|
||||||
|
assert 0 <= status["usage_percent"] <= 100
|
||||||
|
assert status["threshold_percent"] == 85.0
|
||||||
|
assert status["incident"] is (status["usage_percent"] >= 85.0)
|
||||||
|
|
@ -33,6 +33,15 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||||
assert first.runtime_name.startswith("runtime-")
|
assert first.runtime_name.startswith("runtime-")
|
||||||
assert first.runtime_name.endswith(".js")
|
assert first.runtime_name.endswith(".js")
|
||||||
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
||||||
|
initial_mobile_javascript = (
|
||||||
|
len(first.runtime_gzip_bytes)
|
||||||
|
+ len(first.feature_bundles["work-core"].runtime_gzip_bytes)
|
||||||
|
)
|
||||||
|
assert initial_mobile_javascript <= 60 * 1024
|
||||||
|
assert (
|
||||||
|
len(first.feature_bundles["today-timer"].runtime_gzip_bytes)
|
||||||
|
+ len(first.feature_bundles["planning"].runtime_gzip_bytes)
|
||||||
|
) > initial_mobile_javascript
|
||||||
|
|
||||||
changed_frontend = tmp_path / "frontend"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
|
|
@ -100,9 +109,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
)
|
)
|
||||||
optional_block = optional_block.split("];", 1)[0]
|
optional_block = optional_block.split("];", 1)[0]
|
||||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
|
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
|
||||||
|
demand_loaded = {"today-timer", "planning"}
|
||||||
for name, bundle in first.feature_bundles.items():
|
for name, bundle in first.feature_bundles.items():
|
||||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
if name in demand_loaded:
|
||||||
|
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||||
|
else:
|
||||||
|
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||||
|
|
||||||
changed_frontend = tmp_path / "frontend"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ async def test_successful_decision_records_one_completed_privacy_safe_security_e
|
||||||
headers={"Idempotency-Key": "decision-audited"},
|
headers={"Idempotency-Key": "decision-audited"},
|
||||||
)
|
)
|
||||||
|
|
||||||
events = journal.list().events
|
events = journal.list(principal_id=1).events
|
||||||
assert decided.status_code == 201
|
assert decided.status_code == 201
|
||||||
assert [
|
assert [
|
||||||
{"kind": event.kind, "method": event.method, "target": event.target, "status": event.status}
|
{"kind": event.kind, "method": event.method, "target": event.target, "status": event.status}
|
||||||
|
|
@ -282,7 +282,7 @@ async def test_rejected_decision_discards_its_pending_security_event(
|
||||||
)
|
)
|
||||||
|
|
||||||
assert rejected.status_code == 409
|
assert rejected.status_code == 409
|
||||||
assert journal.list().events == []
|
assert journal.list(principal_id=1).events == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
491
tests/test_mobile_recent_work.py
Normal file
491
tests/test_mobile_recent_work.py
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
RECENT_WORK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-recent-work.js"
|
||||||
|
INDEX = Path(__file__).resolve().parents[1] / "frontend" / "index.html"
|
||||||
|
DASHBOARD = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js"
|
||||||
|
CSS = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str) -> dict:
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_is_account_scoped_deduplicated_and_bounded():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_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),
|
||||||
|
}};
|
||||||
|
let login = ' Alice ';
|
||||||
|
const recent = createRecentWork({{storage, getLogin:() => login, limit:5}});
|
||||||
|
for (let number=1; number<=6; number += 1) {{
|
||||||
|
recent.record({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||||
|
}}
|
||||||
|
recent.record({{kind:'issue', repository:'stackchain/dashboard', number:3, title:'Issue 3 updated'}});
|
||||||
|
const alice = recent.items();
|
||||||
|
login = 'bob';
|
||||||
|
recent.record({{kind:'pull', repository:'stackchain/api', number:9, title:'Ship API'}});
|
||||||
|
const bob = recent.items();
|
||||||
|
login = '';
|
||||||
|
const anonymousRecord = recent.record({{kind:'issue', repository:'stackchain/dashboard', number:99, title:'Private'}});
|
||||||
|
const anonymous = recent.items();
|
||||||
|
process.stdout.write(JSON.stringify({{alice,bob,anonymousRecord,anonymous,keys:Array.from(values.keys()).sort()}}));
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert [item["number"] for item in payload["alice"]] == [3, 6, 5, 4, 2]
|
||||||
|
assert payload["alice"][0] == {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 3,
|
||||||
|
"title": "Issue 3 updated",
|
||||||
|
"route": "#/my-work/issue/stackchain/dashboard/3",
|
||||||
|
}
|
||||||
|
assert payload["bob"] == [
|
||||||
|
{
|
||||||
|
"kind": "pull",
|
||||||
|
"repository": "stackchain/api",
|
||||||
|
"number": 9,
|
||||||
|
"title": "Ship API",
|
||||||
|
"route": "#/my-work/pull/stackchain/api/9",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert payload["anonymousRecord"] is False
|
||||||
|
assert payload["anonymous"] == []
|
||||||
|
assert payload["keys"] == [
|
||||||
|
"stackchain.mobile-recent-work.v1.alice",
|
||||||
|
"stackchain.mobile-recent-work.v1.bob",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_renders_safe_rows_and_opens_the_selected_route():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
const values = new Map([
|
||||||
|
['stackchain.mobile-recent-work.v1.alice', JSON.stringify([
|
||||||
|
{{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix mobile queue',route:'#/wrong'}},
|
||||||
|
{{kind:'update',number:42,title:'Review release status'}},
|
||||||
|
{{kind:'pull',repository:'bad/repo/extra',number:1,title:'Unsafe'}},
|
||||||
|
{{kind:'issue',repository:'stackchain/dashboard',number:0,title:'Invalid'}},
|
||||||
|
])],
|
||||||
|
]);
|
||||||
|
function node(tag) {{
|
||||||
|
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||||
|
appendChild(child){{this.children.push(child);return child;}},
|
||||||
|
replaceChildren(...children){{this.children=children;}},
|
||||||
|
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||||
|
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||||
|
click(){{this.listeners.click?.();}},
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
const list=node('div'); const section=node('section'); const opened=[];
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice', document:{{createElement:node}}, list, section,
|
||||||
|
openRoute:route=>opened.push(route),
|
||||||
|
}});
|
||||||
|
const rendered=recent.render();
|
||||||
|
list.children[1].children[0].click();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
rendered,hidden:section.hidden,rows:list.children.map(row=>{{const button=row.children[0];return ({{
|
||||||
|
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
|
||||||
|
primary:button.children[0].children[0].textContent,
|
||||||
|
secondary:button.children[0].children[1].textContent,
|
||||||
|
pin:row.children[1].attributes['data-recent-work-pin'],
|
||||||
|
}});}}),opened,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
"rendered": 2,
|
||||||
|
"hidden": False,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"label": "Open Fix mobile queue, issue stackchain/dashboard #7",
|
||||||
|
"route": "#/my-work/issue/stackchain/dashboard/7",
|
||||||
|
"primary": "Fix mobile queue",
|
||||||
|
"secondary": "Issue · stackchain/dashboard #7",
|
||||||
|
"pin": "pin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Open Review release status, update #42",
|
||||||
|
"route": "#/my-work/update/42",
|
||||||
|
"primary": "Review release status",
|
||||||
|
"secondary": "Update · #42",
|
||||||
|
"pin": "pin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"opened": ["#/my-work/update/42"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_records_offline_first_then_merges_the_server_snapshot():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const values = new Map(); const calls=[]; const status={{textContent:''}};
|
||||||
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||||
|
const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}};
|
||||||
|
const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}};
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage,getLogin:()=>'alice',status,debounceMs:99999,
|
||||||
|
fetchJson:async (url, options={{}})=>{{
|
||||||
|
calls.push([url,options.method||'GET']);
|
||||||
|
return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
recent.record(local);
|
||||||
|
const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}};
|
||||||
|
await recent.sync();
|
||||||
|
process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["immediate"]["items"][0]["number"] == 7
|
||||||
|
assert payload["immediate"]["status"] == "Sync pending."
|
||||||
|
assert payload["immediate"]["state"]["pending"] is True
|
||||||
|
assert [item["number"] for item in payload["settled"]] == [7, 9]
|
||||||
|
assert payload["status"] == ""
|
||||||
|
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_drains_a_newer_same_route_generation_after_an_inflight_response():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const values = new Map(); const calls=[];
|
||||||
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||||
|
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old title'}};
|
||||||
|
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New title'}};
|
||||||
|
let releaseFirst;
|
||||||
|
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage,getLogin:()=>'alice',debounceMs:99999,
|
||||||
|
fetchJson:async (_url, options)=>{{
|
||||||
|
const sent=JSON.parse(options.body);
|
||||||
|
calls.push(sent.title);
|
||||||
|
if (calls.length === 1) return firstResponse;
|
||||||
|
return {{items:[{{...newItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
recent.record(oldItem);
|
||||||
|
const syncing=recent.sync();
|
||||||
|
await Promise.resolve();
|
||||||
|
recent.record(newItem);
|
||||||
|
releaseFirst({{items:[{{...oldItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}});
|
||||||
|
await syncing;
|
||||||
|
process.stdout.write(JSON.stringify({{calls,items:recent.items(),state:recent.state()}}));
|
||||||
|
process.exit(0);
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["calls"] == ["Old title", "New title"]
|
||||||
|
assert payload["items"][0]["title"] == "New title"
|
||||||
|
assert payload["state"] == {"pending": False, "pendingCount": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_retries_a_transient_sync_failure_without_a_lifecycle_event():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const values=new Map(); const timers=[]; let calls=0;
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice',debounceMs:99999,retryMs:25,
|
||||||
|
setTimeout:(callback,delay)=>{{const timer={{callback,delay,cleared:false}};timers.push(timer);return timer;}},
|
||||||
|
clearTimeout:timer=>{{timer.cleared=true;}},
|
||||||
|
fetchJson:async (_url,options)=>{{
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error('temporary outage');
|
||||||
|
return {{items:[JSON.parse(options.body)],pinned:[]}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
recent.record({{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Keep me'}});
|
||||||
|
await recent.sync();
|
||||||
|
const afterFailure={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).map(timer=>timer.delay)}};
|
||||||
|
const retryTimer=timers.find(timer=>!timer.cleared);
|
||||||
|
retryTimer?.callback();
|
||||||
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
|
const settled={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).length}};
|
||||||
|
process.stdout.write(JSON.stringify({{afterFailure,settled}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["afterFailure"] == {
|
||||||
|
"calls": 1,
|
||||||
|
"state": {"pending": True, "pendingCount": 1},
|
||||||
|
"active": [25],
|
||||||
|
}
|
||||||
|
assert payload["settled"] == {
|
||||||
|
"calls": 2,
|
||||||
|
"state": {"pending": False, "pendingCount": 0},
|
||||||
|
"active": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_drains_a_newer_pin_generation_after_an_inflight_response():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const values=new Map(); const calls=[];
|
||||||
|
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old pin'}};
|
||||||
|
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New pin'}};
|
||||||
|
const route='#/my-work/issue/stackchain/dashboard/7';
|
||||||
|
let releaseFirst;
|
||||||
|
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice',debounceMs:99999,
|
||||||
|
fetchJson:async (_url,options)=>{{
|
||||||
|
const sent=JSON.parse(options.body); calls.push(sent.title);
|
||||||
|
if (calls.length === 1) return firstResponse;
|
||||||
|
return {{items:[],pinned:[{{...newItem,route}}]}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
recent.pin(oldItem);
|
||||||
|
const syncing=recent.sync();
|
||||||
|
await Promise.resolve();
|
||||||
|
recent.pin(newItem);
|
||||||
|
releaseFirst({{items:[],pinned:[{{...oldItem,route}}]}});
|
||||||
|
await syncing;
|
||||||
|
process.stdout.write(JSON.stringify({{calls,pinned:recent.pinned(),state:recent.state()}}));
|
||||||
|
process.exit(0);
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["calls"] == ["Old pin", "New pin"]
|
||||||
|
assert payload["pinned"][0]["title"] == "New pin"
|
||||||
|
assert payload["state"] == {"pending": False, "pendingCount": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const item={{kind:'issue',repository:'stackchain/dashboard',number:1477,title:'Pin frequent work',route:'#/my-work/issue/stackchain/dashboard/1477'}};
|
||||||
|
const storageKey='stackchain.mobile-recent-work.v1.alice';
|
||||||
|
const values=new Map([[storageKey,JSON.stringify({{items:[item],pinned:[],pending:[],pinOps:[]}})]]);
|
||||||
|
const status={{textContent:''}}; const calls=[]; const opened=[];
|
||||||
|
function node(tag) {{
|
||||||
|
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||||
|
appendChild(child){{this.children.push(child);return child;}},
|
||||||
|
replaceChildren(...children){{this.children=children;}},
|
||||||
|
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||||
|
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||||
|
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
const recentList=node('div'); const recentSection=node('section');
|
||||||
|
const pinnedList=node('div'); const pinnedSection=node('section');
|
||||||
|
let remote={{items:[item],pinned:[]}};
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice',status,debounceMs:99999,document:{{createElement:node}},
|
||||||
|
list:recentList,section:recentSection,pinnedList,pinnedSection,
|
||||||
|
openRoute:route=>opened.push(route),
|
||||||
|
fetchJson:async (url,options={{}})=>{{
|
||||||
|
calls.push([url,options.method||'GET',JSON.parse(options.body||'null')]);
|
||||||
|
if (options.method==='PUT') remote={{items:[item],pinned:[item]}};
|
||||||
|
if (options.method==='DELETE') remote={{items:[item],pinned:[]}};
|
||||||
|
return remote;
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const pinnedImmediately=recent.pin(item);
|
||||||
|
recent.render();
|
||||||
|
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
|
||||||
|
recentRows:recentList.children.length,
|
||||||
|
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
|
||||||
|
await recent.sync();
|
||||||
|
pinnedList.children[0].children[0].click();
|
||||||
|
const unpinnedImmediately=recent.unpin(item.route);
|
||||||
|
await recent.sync();
|
||||||
|
process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinnedImmediately,settled:recent.pinned(),calls}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["pinnedImmediately"] is True
|
||||||
|
assert payload["immediate"]["pinned"][0]["number"] == 1477
|
||||||
|
assert payload["immediate"]["status"] == "Sync pending."
|
||||||
|
assert payload["immediate"]["recentHidden"] is True
|
||||||
|
assert payload["immediate"]["pinnedHidden"] is False
|
||||||
|
assert payload["immediate"]["recentRows"] == 0
|
||||||
|
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
|
||||||
|
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
|
||||||
|
assert payload["unpinnedImmediately"] is True
|
||||||
|
assert payload["settled"] == []
|
||||||
|
assert payload["calls"] == [
|
||||||
|
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
|
||||||
|
["api/v1/recent-work", "POST", payload["immediate"]["pinned"][0]],
|
||||||
|
[
|
||||||
|
"api/v1/recent-work/pin",
|
||||||
|
"DELETE",
|
||||||
|
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_detail_pin_follows_route_and_toggles_offline_first():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
const values=new Map();
|
||||||
|
function button() {{ return {{hidden:true,textContent:'',attributes:{{}},listeners:{{}},
|
||||||
|
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||||
|
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||||
|
click(){{this.listeners.click?.();}},
|
||||||
|
}}; }}
|
||||||
|
const detailPins=[button(),button(),button(),button()];
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice',detailPins,debounceMs:99999,
|
||||||
|
}});
|
||||||
|
const issue={{kind:'issue',repository:'stackchain/dashboard',number:1489,title:'Pin current detail'}};
|
||||||
|
const pull={{kind:'pull',repository:'stackchain/api',number:20,title:'Ship API'}};
|
||||||
|
recent.setCurrent(issue);
|
||||||
|
const issueReady=detailPins.map(pin=>({{hidden:pin.hidden,text:pin.textContent,
|
||||||
|
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}));
|
||||||
|
detailPins[0].click();
|
||||||
|
const pinned={{items:recent.pinned(),state:recent.state(),buttons:detailPins.map(pin=>({{text:pin.textContent,
|
||||||
|
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}))}};
|
||||||
|
recent.setCurrent(pull);
|
||||||
|
const pullReady={{text:detailPins[1].textContent,label:detailPins[1].attributes['aria-label'],pressed:detailPins[1].attributes['aria-pressed']}};
|
||||||
|
recent.setCurrent(issue);
|
||||||
|
detailPins[2].click();
|
||||||
|
const unpinned={{items:recent.pinned(),recent:recent.items(),state:recent.state(),text:detailPins[0].textContent}};
|
||||||
|
recent.setCurrent(null);
|
||||||
|
process.stdout.write(JSON.stringify({{issueReady,pinned,pullReady,unpinned,hidden:detailPins.map(pin=>pin.hidden)}}));
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["issueReady"] == [
|
||||||
|
{
|
||||||
|
"hidden": False,
|
||||||
|
"text": "Pin",
|
||||||
|
"pressed": "false",
|
||||||
|
"label": "Pin Pin current detail",
|
||||||
|
"action": "pin",
|
||||||
|
}
|
||||||
|
] * 4
|
||||||
|
assert payload["pinned"]["items"][0]["route"] == "#/my-work/issue/stackchain/dashboard/1489"
|
||||||
|
assert payload["pinned"]["state"] == {"pending": True, "pendingCount": 1}
|
||||||
|
assert payload["pinned"]["buttons"] == [
|
||||||
|
{
|
||||||
|
"text": "Pinned",
|
||||||
|
"pressed": "true",
|
||||||
|
"label": "Unpin Pin current detail",
|
||||||
|
"action": "unpin",
|
||||||
|
}
|
||||||
|
] * 4
|
||||||
|
assert payload["pullReady"] == {
|
||||||
|
"text": "Pin",
|
||||||
|
"label": "Pin Ship API",
|
||||||
|
"pressed": "false",
|
||||||
|
}
|
||||||
|
assert payload["unpinned"]["items"] == []
|
||||||
|
assert payload["unpinned"]["recent"] == []
|
||||||
|
assert payload["unpinned"]["state"] == {"pending": True, "pendingCount": 1}
|
||||||
|
assert payload["unpinned"]["text"] == "Pin"
|
||||||
|
assert payload["hidden"] == [True] * 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_work_is_compact_deduplicated_and_promotes_on_open():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
const makeItem=number=>({{kind:'issue',repository:'stackchain/dashboard',number,title:'Issue '+number}});
|
||||||
|
const values=new Map([['stackchain.mobile-recent-work.v1.alice',JSON.stringify({{
|
||||||
|
items:[1,2,3,4,5].map(makeItem),pinned:[1,2,3,4,5].map(makeItem),pending:[],pinOps:[]
|
||||||
|
}})]]);
|
||||||
|
function node(tag) {{ return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||||
|
appendChild(child){{this.children.push(child);return child;}},
|
||||||
|
replaceChildren(...children){{this.children=children;}},
|
||||||
|
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||||
|
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||||
|
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
|
||||||
|
}}; }}
|
||||||
|
const recentList=node('div'), recentSection=node('section');
|
||||||
|
const pinnedList=node('div'), pinnedSection=node('section'), pinnedToggle=node('button');
|
||||||
|
const opened=[];
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice',document:{{createElement:node}},debounceMs:99999,
|
||||||
|
list:recentList,section:recentSection,pinnedList,pinnedSection,pinnedToggle,
|
||||||
|
openRoute:route=>opened.push(route),
|
||||||
|
}});
|
||||||
|
recent.render();
|
||||||
|
const collapsed={{pinnedRows:pinnedList.children.length,recentRows:recentList.children.length,
|
||||||
|
recentHidden:recentSection.hidden,toggleHidden:pinnedToggle.hidden,toggleText:pinnedToggle.textContent,
|
||||||
|
expanded:pinnedToggle.attributes['aria-expanded'],controls:pinnedToggle.attributes['aria-controls']}};
|
||||||
|
pinnedList.children[2].children[0].click();
|
||||||
|
const promoted=recent.pinned().map(item=>item.number);
|
||||||
|
pinnedToggle.click();
|
||||||
|
const expanded={{pinnedRows:pinnedList.children.length,toggleText:pinnedToggle.textContent,
|
||||||
|
ariaExpanded:pinnedToggle.attributes['aria-expanded']}};
|
||||||
|
process.stdout.write(JSON.stringify({{collapsed,promoted,expanded,opened,state:recent.state()}}));
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["collapsed"] == {
|
||||||
|
"pinnedRows": 3,
|
||||||
|
"recentRows": 0,
|
||||||
|
"recentHidden": True,
|
||||||
|
"toggleHidden": False,
|
||||||
|
"toggleText": "Show all 5",
|
||||||
|
"expanded": "false",
|
||||||
|
"controls": "mobile-pinned-work-list",
|
||||||
|
}
|
||||||
|
assert payload["promoted"] == [3, 1, 2, 4, 5]
|
||||||
|
assert payload["expanded"] == {
|
||||||
|
"pinnedRows": 5,
|
||||||
|
"toggleText": "Show fewer",
|
||||||
|
"ariaExpanded": "true",
|
||||||
|
}
|
||||||
|
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/3"]
|
||||||
|
assert payload["state"] == {"pending": True, "pendingCount": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||||
|
html = INDEX.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
css = CSS.read_text()
|
||||||
|
|
||||||
|
assert 'id="mobile-recent-work"' in html
|
||||||
|
assert 'id="mobile-recent-work-list"' in html
|
||||||
|
assert 'id="mobile-pinned-work"' in html
|
||||||
|
assert 'id="mobile-pinned-work-list"' in html
|
||||||
|
assert 'id="mobile-pinned-work-toggle"' in html
|
||||||
|
assert 'aria-controls="mobile-pinned-work-list"' in html
|
||||||
|
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
|
||||||
|
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
||||||
|
assert "createMobileRecentWork({" in dashboard
|
||||||
|
assert "fetchJson:fetchReviewJson" in dashboard
|
||||||
|
assert "status:qs('#mobile-recent-work-status')" in dashboard
|
||||||
|
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
|
||||||
|
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
|
||||||
|
assert "pinnedToggle:qs('#mobile-pinned-work-toggle')" in dashboard
|
||||||
|
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
|
||||||
|
assert "void mobileRecentWork.load();" in dashboard
|
||||||
|
assert "mobileRecentWork.record(item)" in dashboard
|
||||||
|
assert "mobileRecentWork.render()" in dashboard
|
||||||
|
assert "workRoute.sync()" in dashboard
|
||||||
|
assert html.count('data-current-work-pin') == 4
|
||||||
|
assert "detailPins:qsa('[data-current-work-pin]')" in dashboard
|
||||||
|
assert "mobileRecentWork.setCurrent(item)" in dashboard
|
||||||
|
assert "mobileRecentWork.setCurrent(null)" in dashboard
|
||||||
|
assert "[data-recent-work-route]" in css
|
||||||
|
assert "[data-recent-work-pin]" in css
|
||||||
|
assert "[data-current-work-pin]" in css
|
||||||
|
assert "min-height:44px" in css
|
||||||
|
assert "min-width:0" in css
|
||||||
|
|
@ -89,6 +89,200 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_hydrates_syncs_and_preserves_local_order_on_conflict():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map();
|
||||||
|
const defaults = ['attention','today','update','agenda','following','authored','filed','later','draft'];
|
||||||
|
const remote = defaults.slice(); remote.splice(remote.indexOf('following'), 1); remote.splice(1, 0, 'following');
|
||||||
|
let server = {{revision:2, order:remote.slice()}};
|
||||||
|
let conflict = false;
|
||||||
|
const calls = [];
|
||||||
|
const fetchJson = async (url, init={{}}) => {{
|
||||||
|
calls.push([url, init.method || 'GET']);
|
||||||
|
if (!init.method) return JSON.parse(JSON.stringify(server));
|
||||||
|
const payload = JSON.parse(init.body);
|
||||||
|
if (conflict) {{
|
||||||
|
const error = new Error('conflict'); error.status=409;
|
||||||
|
error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(server))}}}};
|
||||||
|
throw error;
|
||||||
|
}}
|
||||||
|
server={{revision:payload.revision + 1, order:payload.order.slice()}};
|
||||||
|
return JSON.parse(JSON.stringify(server));
|
||||||
|
}};
|
||||||
|
(async () => {{
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>'alice', fetchJson,
|
||||||
|
}});
|
||||||
|
await priority.load();
|
||||||
|
const hydrated=priority.getOrder();
|
||||||
|
priority.move('authored', -1);
|
||||||
|
const local=priority.getOrder();
|
||||||
|
conflict=true;
|
||||||
|
server={{revision:3, order:defaults.slice()}};
|
||||||
|
await priority.sync();
|
||||||
|
const conflicted=priority.state();
|
||||||
|
conflict=false;
|
||||||
|
await priority.useLocal();
|
||||||
|
process.stdout.write(JSON.stringify({{hydrated,local,conflicted,settled:priority.state(),server,calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["hydrated"][1] == "following"
|
||||||
|
assert payload["local"] == payload["conflicted"]["order"]
|
||||||
|
assert payload["conflicted"]["status"] == "conflict"
|
||||||
|
assert payload["conflicted"]["remote"] == {
|
||||||
|
"revision": 3,
|
||||||
|
"order": ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"],
|
||||||
|
}
|
||||||
|
assert payload["settled"]["status"] == "ready"
|
||||||
|
assert payload["server"]["revision"] == 4
|
||||||
|
assert payload["server"]["order"] == payload["local"]
|
||||||
|
assert payload["calls"] == [
|
||||||
|
["api/v1/queue-priority", "GET"],
|
||||||
|
["api/v1/queue-priority", "PUT"],
|
||||||
|
["api/v1/queue-priority", "PUT"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_coalesces_rapid_edits_and_publishes_latest_order():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map(); const payloads=[]; const timers=[];
|
||||||
|
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
|
||||||
|
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
|
||||||
|
const fetchJson = async (_url, init) => {{
|
||||||
|
const payload=JSON.parse(init.body); payloads.push(payload);
|
||||||
|
return {{revision:payload.revision+1,order:payload.order}};
|
||||||
|
}};
|
||||||
|
(async () => {{
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer, debounceMs:150,
|
||||||
|
}});
|
||||||
|
for (let index=0; index<4; index += 1) priority.move('following', -1);
|
||||||
|
const before=payloads.length;
|
||||||
|
const active=timers.filter(timer=>!timer.cancelled);
|
||||||
|
active[0].callback();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
process.stdout.write(JSON.stringify({{before,active:active.map(timer=>timer.delay),payloads,state:priority.state()}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["before"] == 0
|
||||||
|
assert payload["active"] == [150]
|
||||||
|
assert len(payload["payloads"]) == 1
|
||||||
|
assert payload["payloads"][0]["revision"] == 0
|
||||||
|
assert payload["payloads"][0]["order"] == payload["state"]["order"]
|
||||||
|
assert payload["state"]["revision"] == 1
|
||||||
|
assert payload["state"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_retries_transient_failure_without_another_edit():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map(); const timers=[]; let calls=0;
|
||||||
|
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
|
||||||
|
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
|
||||||
|
const fetchJson = async (_url, init) => {{
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {{ const error=new Error('temporary'); error.status=503; throw error; }}
|
||||||
|
const payload=JSON.parse(init.body);
|
||||||
|
return {{revision:payload.revision+1,order:payload.order}};
|
||||||
|
}};
|
||||||
|
(async () => {{
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer,
|
||||||
|
debounceMs:150, retryBaseMs:1000, retryMaxMs:30000,
|
||||||
|
}});
|
||||||
|
priority.move('following', -1);
|
||||||
|
timers.find(timer=>!timer.cancelled).callback();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
const afterFailure=priority.state();
|
||||||
|
const retry=timers.filter(timer=>!timer.cancelled).at(-1);
|
||||||
|
retry.callback();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
process.stdout.write(JSON.stringify({{calls,afterFailure,retryDelay:retry.delay,settled:priority.state()}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["afterFailure"]["pending"] is True
|
||||||
|
assert payload["afterFailure"]["status"] == "pending"
|
||||||
|
assert payload["retryDelay"] == 1000
|
||||||
|
assert payload["calls"] == 2
|
||||||
|
assert payload["settled"]["pending"] is False
|
||||||
|
assert payload["settled"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_refreshes_clean_device_when_foregrounded():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map(); const windowListeners={{}}; const documentListeners={{}};
|
||||||
|
const remoteOrder=['attention','following','today','update','agenda','authored','filed','later','draft'];
|
||||||
|
let calls=0;
|
||||||
|
const documentRef={{hidden:false,addEventListener:(name, callback)=>documentListeners[name]=callback}};
|
||||||
|
const windowRef={{addEventListener:(name, callback)=>windowListeners[name]=callback}};
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>'alice', fetchJson:async () => {{ calls += 1; return {{revision:4,order:remoteOrder.slice()}}; }},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
priority.startLifecycle({{window:windowRef,document:documentRef}});
|
||||||
|
documentListeners.visibilitychange();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
process.stdout.write(JSON.stringify({{calls,state:priority.state(),listeners:[...Object.keys(windowListeners),...Object.keys(documentListeners)]}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert set(payload["listeners"]) == {"online", "visibilitychange"}
|
||||||
|
assert payload["calls"] == 1
|
||||||
|
assert payload["state"]["revision"] == 4
|
||||||
|
assert payload["state"]["order"][1] == "following"
|
||||||
|
assert payload["state"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_discards_in_flight_results_after_account_switch():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map(); let login='alice'; let rejectLoad;
|
||||||
|
const fetchJson = () => new Promise((_resolve, reject) => {{ rejectLoad=reject; }});
|
||||||
|
(async () => {{
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>login, fetchJson,
|
||||||
|
}});
|
||||||
|
const loading=priority.load();
|
||||||
|
login='bob';
|
||||||
|
rejectLoad(new Error('alice offline'));
|
||||||
|
await loading;
|
||||||
|
process.stdout.write(JSON.stringify({{state:priority.state(),keys:Array.from(values.keys())}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["state"]["status"] == "ready"
|
||||||
|
assert payload["state"]["order"] == [
|
||||||
|
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"
|
||||||
|
]
|
||||||
|
assert payload["keys"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
|
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
|
@ -127,7 +321,7 @@ process.stdout.write(JSON.stringify({{
|
||||||
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||||
"earlierLabel": "Move Following earlier",
|
"earlierLabel": "Move Following earlier",
|
||||||
"laterLabel": "Move Following later",
|
"laterLabel": "Move Following later",
|
||||||
"status": "Following moved earlier.",
|
"status": "Sync pending.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -399,6 +593,21 @@ async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe()
|
||||||
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
|
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_conflict_actions():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="mobile-queue-priority-conflict"' in html
|
||||||
|
assert 'id="keep-local-mobile-queue-priority"' in html
|
||||||
|
assert 'id="use-remote-mobile-queue-priority"' in html
|
||||||
|
assert "fetchJson: fetchReviewJson" in html
|
||||||
|
assert "void mobileQueuePriority.load();" in html
|
||||||
|
assert "mobileQueuePriority.startLifecycle({window, document});" in html
|
||||||
|
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
|
||||||
|
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
|
||||||
|
assert "error.payload = payload;" in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
|
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,9 @@ def test_eager_private_stores_share_the_private_filesystem_boundary(tmp_path, bu
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"exercise",
|
"exercise",
|
||||||
[
|
[
|
||||||
lambda path: SecurityEventStore(path, clock=lambda: 1).record("sign_in"),
|
lambda path: SecurityEventStore(path, clock=lambda: 1).record(
|
||||||
|
"sign_in", principal_id=42
|
||||||
|
),
|
||||||
lambda path: LoginAttemptStore(
|
lambda path: LoginAttemptStore(
|
||||||
path, clock=lambda: 1, max_failures=3, window_seconds=60
|
path, clock=lambda: 1, max_failures=3, window_seconds=60
|
||||||
).record_failure("203.0.113.10"),
|
).record_failure("203.0.113.10"),
|
||||||
|
|
|
||||||
|
|
@ -1599,7 +1599,7 @@ async def test_assigned_pull_merge_reports_success_and_retains_pending_audit_whe
|
||||||
lifecycle = []
|
lifecycle = []
|
||||||
|
|
||||||
class InterruptedJournal:
|
class InterruptedJournal:
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
lifecycle.append(("reserve", kind, target))
|
lifecycle.append(("reserve", kind, target))
|
||||||
return "merge-operation"
|
return "merge-operation"
|
||||||
|
|
||||||
|
|
@ -1640,7 +1640,7 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
|
||||||
merged = False
|
merged = False
|
||||||
|
|
||||||
class LifecycleJournal:
|
class LifecycleJournal:
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
calls.append(("reserve", kind, target))
|
calls.append(("reserve", kind, target))
|
||||||
return "merge-operation"
|
return "merge-operation"
|
||||||
|
|
||||||
|
|
@ -1782,7 +1782,7 @@ async def test_assigned_pull_merge_discards_audit_reservation_after_definite_rej
|
||||||
lifecycle = []
|
lifecycle = []
|
||||||
|
|
||||||
class LifecycleJournal:
|
class LifecycleJournal:
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
lifecycle.append(("reserve", kind, target))
|
lifecycle.append(("reserve", kind, target))
|
||||||
return "merge-operation"
|
return "merge-operation"
|
||||||
|
|
||||||
|
|
@ -2240,7 +2240,7 @@ async def test_source_branch_cleanup_endpoint_audits_and_deletes_the_exact_merge
|
||||||
lifecycle = []
|
lifecycle = []
|
||||||
|
|
||||||
class Journal:
|
class Journal:
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
lifecycle.append(("reserve", kind, target))
|
lifecycle.append(("reserve", kind, target))
|
||||||
return "branch-cleanup"
|
return "branch-cleanup"
|
||||||
|
|
||||||
|
|
@ -2740,7 +2740,7 @@ async def test_release_rollback_endpoint_requires_failed_exact_commit_and_audits
|
||||||
lifecycle = []
|
lifecycle = []
|
||||||
|
|
||||||
class Journal:
|
class Journal:
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
lifecycle.append(("reserve", kind, target))
|
lifecycle.append(("reserve", kind, target))
|
||||||
return "rollback-operation"
|
return "rollback-operation"
|
||||||
|
|
||||||
|
|
|
||||||
75
tests/test_queue_priority_store.py
Normal file
75
tests/test_queue_priority_store.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_priority_is_revisioned_encrypted_and_account_scoped(tmp_path):
|
||||||
|
database = tmp_path / "queue-priority.sqlite3"
|
||||||
|
store = QueuePriorityStore(database, encryption_key=b"q" * 32)
|
||||||
|
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||||
|
preferred.remove("following")
|
||||||
|
preferred.insert(1, "following")
|
||||||
|
|
||||||
|
created = store.replace(" Timmy ", 0, preferred)
|
||||||
|
|
||||||
|
assert created == {"revision": 1, "order": preferred}
|
||||||
|
assert QueuePriorityStore(database, encryption_key=b"q" * 32).get("timmy") == created
|
||||||
|
assert store.get("alexander") == {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v1:")
|
||||||
|
assert "following" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_queue_priority_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_QUEUE_PRIORITY_DB", str(tmp_path / "queue-priority.sqlite3"))
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "Timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||||
|
preferred.remove("following")
|
||||||
|
preferred.insert(1, "following")
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
|
forbidden = await client.put(
|
||||||
|
"/api/v1/queue-priority", json={"revision": 0, "order": preferred}
|
||||||
|
)
|
||||||
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
|
saved = await client.put(
|
||||||
|
"/api/v1/queue-priority", json={"revision": 0, "order": preferred}, headers=headers
|
||||||
|
)
|
||||||
|
stale = await client.put(
|
||||||
|
"/api/v1/queue-priority",
|
||||||
|
json={"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
fetched = await client.get("/api/v1/queue-priority")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json() == {"revision": 1, "order": preferred}
|
||||||
|
assert stale.status_code == 409
|
||||||
|
assert stale.json()["detail"] == {
|
||||||
|
"message": "Queue priority changed on another device.",
|
||||||
|
"snapshot": saved.json(),
|
||||||
|
}
|
||||||
|
assert fetched.json() == saved.json()
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
106
tests/test_recent_work_api.py
Normal file
106
tests/test_recent_work_api.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_account_scoped(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||||
|
active_login = "Timmy"
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": active_login}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
entry = {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 1475,
|
||||||
|
"title": "Sync recent work",
|
||||||
|
"route": "#/my-work/issue/stackchain/dashboard/1475",
|
||||||
|
}
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
forbidden = await client.post("/api/v1/recent-work", json=entry)
|
||||||
|
headers = {
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
saved = await client.post("/api/v1/recent-work", json=entry, headers=headers)
|
||||||
|
fetched = await client.get("/api/v1/recent-work")
|
||||||
|
active_login = "Alexander"
|
||||||
|
other_account = await client.get("/api/v1/recent-work")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json() == {"items": [entry], "pinned": []}
|
||||||
|
assert fetched.json() == saved.json()
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
|
assert other_account.json() == {"items": [], "pinned": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_recent_work_pin_api_is_csrf_protected_and_account_scoped(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||||
|
active_login = "Timmy"
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": active_login}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
entry = {
|
||||||
|
"kind": "pull",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 1476,
|
||||||
|
"title": "Sync recent work",
|
||||||
|
"route": "#/my-work/pull/stackchain/dashboard/1476",
|
||||||
|
}
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
forbidden = await client.put("/api/v1/recent-work/pin", json=entry)
|
||||||
|
headers = {
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
pinned = await client.put("/api/v1/recent-work/pin", json=entry, headers=headers)
|
||||||
|
active_login = "Alexander"
|
||||||
|
isolated = await client.get("/api/v1/recent-work")
|
||||||
|
active_login = "Timmy"
|
||||||
|
unpinned = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/recent-work/pin",
|
||||||
|
json={"route": entry["route"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert pinned.status_code == 200
|
||||||
|
assert pinned.json() == {"items": [], "pinned": [entry]}
|
||||||
|
assert isolated.json() == {"items": [], "pinned": []}
|
||||||
|
assert unpinned.status_code == 200
|
||||||
|
assert unpinned.json() == {"items": [], "pinned": []}
|
||||||
89
tests/test_recent_work_store.py
Normal file
89
tests/test_recent_work_store.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from src.recent_work_store import RecentWorkStore
|
||||||
|
|
||||||
|
|
||||||
|
def item(number: int, *, title: str | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": number,
|
||||||
|
"title": title or f"Issue {number}",
|
||||||
|
"route": f"#/my-work/issue/stackchain/dashboard/{number}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_path):
|
||||||
|
database = tmp_path / "recent-work.sqlite3"
|
||||||
|
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||||
|
|
||||||
|
for number in range(1, 7):
|
||||||
|
store.record(" Timmy ", item(number))
|
||||||
|
expected = store.record("timmy", item(3, title="Issue 3 updated"))
|
||||||
|
|
||||||
|
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
|
||||||
|
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
|
||||||
|
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v1:")
|
||||||
|
assert "Issue 3 updated" not in payload
|
||||||
|
assert "#/my-work/issue" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path):
|
||||||
|
database = tmp_path / "recent-work.sqlite3"
|
||||||
|
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||||
|
|
||||||
|
store.record("timmy", item(1))
|
||||||
|
pinned = store.pin("timmy", item(1))
|
||||||
|
for number in range(2, 8):
|
||||||
|
store.record("timmy", item(number))
|
||||||
|
|
||||||
|
assert pinned["pinned"] == [item(1)]
|
||||||
|
assert [entry["number"] for entry in store.get("timmy")["items"]] == [7, 6, 5, 4, 3]
|
||||||
|
assert store.get("timmy")["pinned"] == [item(1)]
|
||||||
|
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||||
|
|
||||||
|
store.record("timmy", item(1, title="Issue 1 current"))
|
||||||
|
unpinned = store.unpin("timmy", item(1)["route"])
|
||||||
|
|
||||||
|
assert unpinned["pinned"] == []
|
||||||
|
assert unpinned["items"][0] == item(1, title="Issue 1 current")
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert "Issue 1" not in payload
|
||||||
|
assert "#/my-work/issue" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopening_pinned_work_promotes_it_for_every_device(tmp_path):
|
||||||
|
store = RecentWorkStore(
|
||||||
|
tmp_path / "recent-work.sqlite3",
|
||||||
|
encryption_key=b"r" * 32,
|
||||||
|
)
|
||||||
|
for number in range(1, 5):
|
||||||
|
store.pin("timmy", item(number))
|
||||||
|
|
||||||
|
reopened = store.record("timmy", item(1, title="Issue 1 current"))
|
||||||
|
|
||||||
|
assert [entry["number"] for entry in reopened["pinned"]] == [1, 4, 3, 2]
|
||||||
|
assert reopened["pinned"][0]["title"] == "Issue 1 current"
|
||||||
|
assert store.get("timmy")["pinned"] == reopened["pinned"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
|
||||||
|
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
|
||||||
|
|
||||||
|
invalid = item(1)
|
||||||
|
invalid["route"] = "https://attacker.example/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
store.record("timmy", invalid)
|
||||||
|
except ValueError as error:
|
||||||
|
assert str(error) == "recent work item is invalid"
|
||||||
|
else:
|
||||||
|
raise AssertionError("invalid route was accepted")
|
||||||
|
|
@ -7,6 +7,8 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||||
|
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||||
|
from src.recent_work_store import RecentWorkStore
|
||||||
from src.saved_search_store import SavedSearchStore
|
from src.saved_search_store import SavedSearchStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -111,3 +113,59 @@ def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tm
|
||||||
assert CompletedFiledReviewStore(
|
assert CompletedFiledReviewStore(
|
||||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||||
).get("timmy") == {"receipts": [private_receipt]}
|
).get("timmy") == {"receipts": [private_receipt]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
|
||||||
|
state = tmp_path / "state"
|
||||||
|
path = state / "queue-priority.sqlite3"
|
||||||
|
preferred = list(DEFAULT_QUEUE_ORDER)
|
||||||
|
preferred.remove("following")
|
||||||
|
preferred.insert(1, "following")
|
||||||
|
expected = QueuePriorityStore(path, encryption_key=b"o" * 32).replace(
|
||||||
|
"timmy", 0, preferred
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = run_rotation(state)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout)["queue-priority"] == {
|
||||||
|
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||||
|
}
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v2:next:")
|
||||||
|
assert QueuePriorityStore(
|
||||||
|
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||||
|
).get("timmy") == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_command_rewraps_recent_work_without_printing_titles(tmp_path):
|
||||||
|
state = tmp_path / "state"
|
||||||
|
path = state / "recent-work.sqlite3"
|
||||||
|
private_item = {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "private/canary",
|
||||||
|
"number": 1475,
|
||||||
|
"title": "Secret release investigation",
|
||||||
|
"route": "#/my-work/issue/private/canary/1475",
|
||||||
|
}
|
||||||
|
expected = RecentWorkStore(path, encryption_key=b"o" * 32).record("timmy", private_item)
|
||||||
|
|
||||||
|
completed = run_rotation(state)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout)["recent-work"] == {
|
||||||
|
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||||
|
}
|
||||||
|
assert private_item["title"] not in completed.stdout
|
||||||
|
assert "timmy" not in completed.stdout
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v2:next:")
|
||||||
|
assert RecentWorkStore(
|
||||||
|
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||||
|
).get("timmy") == expected
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,32 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||||
assert b"stackchain_session" not in persisted
|
assert b"stackchain_session" not in persisted
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_security_activity_does_not_follow_a_previous_upstream_identity(
|
||||||
|
security_access, monkeypatch
|
||||||
|
):
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as timmy:
|
||||||
|
await timmy.post(
|
||||||
|
"/api/v1/session",
|
||||||
|
json={"access_token": "correct horse battery staple", "device_label": "Timmy phone"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def other_user():
|
||||||
|
return {"id": 84, "login": "other"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", other_user)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as other:
|
||||||
|
signed_in = await other.post(
|
||||||
|
"/api/v1/session",
|
||||||
|
json={"access_token": "correct horse battery staple", "device_label": "Other phone"},
|
||||||
|
)
|
||||||
|
activity = await other.get("/api/v1/security-events")
|
||||||
|
|
||||||
|
assert signed_in.status_code == 200
|
||||||
|
assert [event["device_label"] for event in activity.json()["events"]] == ["Other phone"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_tampered_security_activity_returns_no_partial_history(security_access):
|
async def test_tampered_security_activity_returns_no_partial_history(security_access):
|
||||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||||
|
|
@ -211,7 +237,7 @@ async def test_passkey_enrollment_registry_failure_discards_reserved_event(
|
||||||
|
|
||||||
events = SecurityEventStore(
|
events = SecurityEventStore(
|
||||||
security_access / "security.sqlite3", clock=lambda: 0
|
security_access / "security.sqlite3", clock=lambda: 0
|
||||||
).list(limit=10).events
|
).list(principal_id=42, limit=10).events
|
||||||
assert enrolled.status_code == 503
|
assert enrolled.status_code == 503
|
||||||
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
|
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
|
||||||
assert all(event.kind != "passkey_enrolled" for event in events)
|
assert all(event.kind != "passkey_enrolled" for event in events)
|
||||||
|
|
@ -596,7 +622,8 @@ async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
|
||||||
return {"id": 42, "deleted": True}
|
return {"id": 42, "deleted": True}
|
||||||
|
|
||||||
class Journal:
|
class Journal:
|
||||||
def reserve(self, kind, *, target):
|
def reserve(self, kind, *, principal_id, target):
|
||||||
|
assert principal_id == 42
|
||||||
journal_calls.append(("reserve", kind, target))
|
journal_calls.append(("reserve", kind, target))
|
||||||
return "operation-42"
|
return "operation-42"
|
||||||
|
|
||||||
|
|
@ -873,7 +900,7 @@ async def test_sign_out_is_journaled_before_the_session_is_removed(security_acce
|
||||||
|
|
||||||
events = SecurityEventStore(
|
events = SecurityEventStore(
|
||||||
security_access / "security.sqlite3", clock=lambda: 0
|
security_access / "security.sqlite3", clock=lambda: 0
|
||||||
).list(limit=10).events
|
).list(principal_id=42, limit=10).events
|
||||||
assert signed_out.status_code == 200
|
assert signed_out.status_code == 200
|
||||||
assert [(event.kind, event.device_label) for event in events[:2]] == [
|
assert [(event.kind, event.device_label) for event in events[:2]] == [
|
||||||
("sign_out", None),
|
("sign_out", None),
|
||||||
|
|
@ -974,7 +1001,7 @@ async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_acc
|
||||||
|
|
||||||
events = SecurityEventStore(
|
events = SecurityEventStore(
|
||||||
security_access / "security.sqlite3", clock=lambda: 0
|
security_access / "security.sqlite3", clock=lambda: 0
|
||||||
).list(limit=10).events
|
).list(principal_id=42, limit=10).events
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert events[0].kind == "all_sessions_revoked"
|
assert events[0].kind == "all_sessions_revoked"
|
||||||
assert events[0].target == "all_devices"
|
assert events[0].target == "all_devices"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ def test_release_rollback_has_a_specific_security_activity_label():
|
||||||
assert "release_rollback_prepared: 'Release rollback prepared'" in source
|
assert "release_rollback_prepared: 'Release rollback prepared'" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_job_retry_has_a_specific_security_activity_label():
|
||||||
|
source = SECURITY_CENTER.read_text()
|
||||||
|
|
||||||
|
assert "ci_job_retried: 'CI job retried'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
|
||||||
store = SecurityEventStore(
|
store = SecurityEventStore(
|
||||||
tmp_path / "security.sqlite3", clock=lambda: 1_000, encryption_key=PRIVATE_KEY
|
tmp_path / "security.sqlite3", clock=lambda: 1_000, encryption_key=PRIVATE_KEY
|
||||||
)
|
)
|
||||||
store.record("issue_closed", method="passkey", target="private/repo#42")
|
store.record("issue_closed", principal_id=42, method="passkey", target="private/repo#42")
|
||||||
|
|
||||||
with sqlite3.connect(store.path) as connection:
|
with sqlite3.connect(store.path) as connection:
|
||||||
columns = {
|
columns = {
|
||||||
|
|
@ -22,6 +22,7 @@ def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
|
||||||
assert columns == {
|
assert columns == {
|
||||||
"id",
|
"id",
|
||||||
"payload",
|
"payload",
|
||||||
|
"principal_id",
|
||||||
"created_at",
|
"created_at",
|
||||||
"status",
|
"status",
|
||||||
"operation_id",
|
"operation_id",
|
||||||
|
|
@ -38,7 +39,7 @@ def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_pa
|
||||||
}
|
}
|
||||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||||
|
|
||||||
store.record(**canaries)
|
store.record(principal_id=42, **canaries)
|
||||||
|
|
||||||
with sqlite3.connect(path) as connection:
|
with sqlite3.connect(path) as connection:
|
||||||
payload = connection.execute(
|
payload = connection.execute(
|
||||||
|
|
@ -48,13 +49,13 @@ def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_pa
|
||||||
database_bytes = path.read_bytes()
|
database_bytes = path.read_bytes()
|
||||||
assert all(value.encode() not in database_bytes for value in canaries.values())
|
assert all(value.encode() not in database_bytes for value in canaries.values())
|
||||||
reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY)
|
reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY)
|
||||||
event = reopened.list(limit=10).events[0]
|
event = reopened.list(principal_id=42, limit=10).events[0]
|
||||||
assert (event.kind, event.method, event.device_label, event.target) == tuple(
|
assert (event.kind, event.method, event.device_label, event.target) == tuple(
|
||||||
canaries.values()
|
canaries.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_path):
|
def test_legacy_security_events_migrate_encrypted_but_remain_unattributed(tmp_path):
|
||||||
path = tmp_path / "security.sqlite3"
|
path = tmp_path / "security.sqlite3"
|
||||||
with sqlite3.connect(path) as connection:
|
with sqlite3.connect(path) as connection:
|
||||||
connection.executescript(
|
connection.executescript(
|
||||||
|
|
@ -80,17 +81,8 @@ def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_p
|
||||||
)
|
)
|
||||||
|
|
||||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||||
page = store.list(limit=1)
|
assert store.list(principal_id=42, limit=10).events == []
|
||||||
older = store.list(limit=10, cursor=page.next_cursor)
|
|
||||||
|
|
||||||
assert [(event.id, event.kind, event.created_at, event.status) for event in page.events] == [
|
|
||||||
(9, "legacy_issue_closed_canary", 901, "pending")
|
|
||||||
]
|
|
||||||
assert [(event.id, event.kind, event.created_at, event.status) for event in older.events] == [
|
|
||||||
(7, "legacy_sign_in_canary", 900, "completed")
|
|
||||||
]
|
|
||||||
store.finalize("operation-9")
|
store.finalize("operation-9")
|
||||||
assert store.list(limit=1).events[0].status == "completed"
|
|
||||||
with sqlite3.connect(path) as connection:
|
with sqlite3.connect(path) as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT id, payload FROM security_events ORDER BY id"
|
"SELECT id, payload FROM security_events ORDER BY id"
|
||||||
|
|
@ -105,12 +97,12 @@ def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_p
|
||||||
def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
|
def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
|
||||||
path = tmp_path / "security.sqlite3"
|
path = tmp_path / "security.sqlite3"
|
||||||
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
|
||||||
store.record("issue_closed", target="private/repo#42")
|
store.record("issue_closed", principal_id=42, target="private/repo#42")
|
||||||
|
|
||||||
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
||||||
SecurityEventStore(
|
SecurityEventStore(
|
||||||
path, clock=lambda: 1_001, encryption_key=b"x" * 32
|
path, clock=lambda: 1_001, encryption_key=b"x" * 32
|
||||||
).list(limit=10)
|
).list(principal_id=42, limit=10)
|
||||||
|
|
||||||
with sqlite3.connect(path) as connection:
|
with sqlite3.connect(path) as connection:
|
||||||
payload = connection.execute(
|
payload = connection.execute(
|
||||||
|
|
@ -122,7 +114,7 @@ def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
|
||||||
(payload[:-1] + replacement,),
|
(payload[:-1] + replacement,),
|
||||||
)
|
)
|
||||||
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
|
||||||
store.list(limit=10)
|
store.list(principal_id=42, limit=10)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_security_activity_encryption_key_fails_with_store_error(
|
def test_missing_security_activity_encryption_key_fails_with_store_error(
|
||||||
|
|
@ -140,20 +132,21 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
|
||||||
|
|
||||||
store.record(
|
store.record(
|
||||||
"sign_in",
|
"sign_in",
|
||||||
|
principal_id=42,
|
||||||
method="token",
|
method="token",
|
||||||
device_label=" Timmy Phone " + "x" * 80,
|
device_label=" Timmy Phone " + "x" * 80,
|
||||||
target="dashboard",
|
target="dashboard",
|
||||||
)
|
)
|
||||||
now[0] += 1
|
now[0] += 1
|
||||||
store.record("device_revoked", device_label="Old phone", target="device")
|
store.record("device_revoked", principal_id=42, device_label="Old phone", target="device")
|
||||||
|
|
||||||
page = store.list(limit=1)
|
page = store.list(principal_id=42, limit=1)
|
||||||
assert [(event.kind, event.device_label, event.target) for event in page.events] == [
|
assert [(event.kind, event.device_label, event.target) for event in page.events] == [
|
||||||
("device_revoked", "Old phone", "device")
|
("device_revoked", "Old phone", "device")
|
||||||
]
|
]
|
||||||
assert page.next_cursor is not None
|
assert page.next_cursor is not None
|
||||||
|
|
||||||
older = store.list(limit=10, cursor=page.next_cursor)
|
older = store.list(principal_id=42, limit=10, cursor=page.next_cursor)
|
||||||
assert older.events[0].kind == "sign_in"
|
assert older.events[0].kind == "sign_in"
|
||||||
assert older.events[0].method == "token"
|
assert older.events[0].method == "token"
|
||||||
assert older.events[0].device_label == ("Timmy Phone " + "x" * 52)
|
assert older.events[0].device_label == ("Timmy Phone " + "x" * 52)
|
||||||
|
|
@ -162,10 +155,29 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
|
||||||
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
|
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
|
||||||
}
|
}
|
||||||
assert columns == {
|
assert columns == {
|
||||||
"id", "payload", "created_at", "status", "operation_id",
|
"id", "payload", "principal_id", "created_at", "status", "operation_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_events_are_isolated_by_principal_across_cursor_pages(tmp_path):
|
||||||
|
now = [1_000]
|
||||||
|
store = SecurityEventStore(
|
||||||
|
tmp_path / "security.sqlite3", clock=lambda: now[0], encryption_key=PRIVATE_KEY
|
||||||
|
)
|
||||||
|
store.record("sign_in", principal_id=42, device_label="Timmy phone")
|
||||||
|
now[0] += 1
|
||||||
|
store.record("sign_in", principal_id=84, device_label="Other phone")
|
||||||
|
now[0] += 1
|
||||||
|
store.record("device_revoked", principal_id=42, device_label="Old Timmy phone")
|
||||||
|
|
||||||
|
first = store.list(principal_id=42, limit=1)
|
||||||
|
second = store.list(principal_id=42, limit=10, cursor=first.next_cursor)
|
||||||
|
|
||||||
|
assert [event.device_label for event in first.events] == ["Old Timmy phone"]
|
||||||
|
assert [event.device_label for event in second.events] == ["Timmy phone"]
|
||||||
|
assert all(event.device_label != "Other phone" for event in first.events + second.events)
|
||||||
|
|
||||||
|
|
||||||
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||||
now = [0]
|
now = [0]
|
||||||
store = SecurityEventStore(
|
store = SecurityEventStore(
|
||||||
|
|
@ -176,32 +188,32 @@ def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||||
)
|
)
|
||||||
for index in range(4):
|
for index in range(4):
|
||||||
now[0] = index
|
now[0] = index
|
||||||
store.record("sign_in", method="token", device_label=f"Device {index}")
|
store.record("sign_in", principal_id=42, method="token", device_label=f"Device {index}")
|
||||||
|
|
||||||
assert [event.device_label for event in store.list(limit=10).events] == [
|
assert [event.device_label for event in store.list(principal_id=42, limit=10).events] == [
|
||||||
"Device 3", "Device 2", "Device 1"
|
"Device 3", "Device 2", "Device 1"
|
||||||
]
|
]
|
||||||
|
|
||||||
now[0] = 20
|
now[0] = 20
|
||||||
store.record("sign_out", device_label="Current")
|
store.record("sign_out", principal_id=42, device_label="Current")
|
||||||
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
assert [event.kind for event in store.list(principal_id=42, limit=10).events] == ["sign_out"]
|
||||||
|
|
||||||
|
|
||||||
def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||||
|
|
||||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
operation_id = store.reserve("issue_closed", principal_id=42, target="stackchain/api#7")
|
||||||
|
|
||||||
pending = SecurityEventStore(
|
pending = SecurityEventStore(
|
||||||
tmp_path / "security.sqlite3", clock=lambda: 1_001
|
tmp_path / "security.sqlite3", clock=lambda: 1_001
|
||||||
).list(limit=10).events
|
).list(principal_id=42, limit=10).events
|
||||||
assert [(event.kind, event.target, event.status) for event in pending] == [
|
assert [(event.kind, event.target, event.status) for event in pending] == [
|
||||||
("issue_closed", "stackchain/api#7", "pending")
|
("issue_closed", "stackchain/api#7", "pending")
|
||||||
]
|
]
|
||||||
|
|
||||||
store.finalize(operation_id)
|
store.finalize(operation_id)
|
||||||
|
|
||||||
completed = store.list(limit=10).events
|
completed = store.list(principal_id=42, limit=10).events
|
||||||
assert [(event.kind, event.target, event.status) for event in completed] == [
|
assert [(event.kind, event.target, event.status) for event in completed] == [
|
||||||
("issue_closed", "stackchain/api#7", "completed")
|
("issue_closed", "stackchain/api#7", "completed")
|
||||||
]
|
]
|
||||||
|
|
@ -209,8 +221,8 @@ def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||||
|
|
||||||
def test_failed_operation_can_discard_its_pending_reservation(tmp_path):
|
def test_failed_operation_can_discard_its_pending_reservation(tmp_path):
|
||||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
operation_id = store.reserve("issue_closed", principal_id=42, target="stackchain/api#7")
|
||||||
|
|
||||||
store.discard(operation_id)
|
store.discard(operation_id)
|
||||||
|
|
||||||
assert store.list(limit=10).events == []
|
assert store.list(principal_id=42, limit=10).events == []
|
||||||
|
|
|
||||||
|
|
@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/mobile-task-dock.js",
|
"/dashboard/static/mobile-task-dock.js",
|
||||||
"/dashboard/static/mobile-first-task.js",
|
"/dashboard/static/mobile-first-task.js",
|
||||||
"/dashboard/static/mobile-work-entry.js",
|
"/dashboard/static/mobile-work-entry.js",
|
||||||
|
"/dashboard/static/mobile-recent-work.js",
|
||||||
"/dashboard/static/mobile-queue-priority.js",
|
"/dashboard/static/mobile-queue-priority.js",
|
||||||
"/dashboard/static/mobile-queue-launcher.js",
|
"/dashboard/static/mobile-queue-launcher.js",
|
||||||
"/dashboard/static/mobile-delivery-recovery.js",
|
"/dashboard/static/mobile-delivery-recovery.js",
|
||||||
|
|
|
||||||
|
|
@ -326,8 +326,9 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
|
||||||
assert result["remaining"] == 1
|
assert result["remaining"] == 1
|
||||||
|
|
||||||
|
|
||||||
def run_mounted_confirmation(*, fail_move: bool = False) -> dict:
|
def run_mounted_confirmation(*, fail_move: bool = False, fail_refresh: bool = False) -> dict:
|
||||||
failure = "true" if fail_move else "false"
|
failure = "true" if fail_move else "false"
|
||||||
|
refresh_failure = "true" if fail_refresh else "false"
|
||||||
scenario = """
|
scenario = """
|
||||||
const mount=require('./frontend/today-week-reschedule.js').mount;
|
const mount=require('./frontend/today-week-reschedule.js').mount;
|
||||||
class Element {
|
class Element {
|
||||||
|
|
@ -361,7 +362,9 @@ const week={load:async()=>weekState,review:()=>({days:[{
|
||||||
}]}),adopt(){}};
|
}]}),adopt(){}};
|
||||||
const api=async(url)=>url==='api/v1/today' ? today : move;
|
const api=async(url)=>url==='api/v1/today' ? today : move;
|
||||||
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today,
|
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today,
|
||||||
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{},warm(){},
|
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{
|
||||||
|
if(__FAIL_REFRESH__)throw new Error('Refresh unavailable.');
|
||||||
|
},warm(){},
|
||||||
continueToday:async()=>{},announce(){},schedule:callback=>callback()});
|
continueToday:async()=>{},announce(){},schedule:callback=>callback()});
|
||||||
await selectors['[data-work-session-reschedule-week]'].emit('click');
|
await selectors['[data-work-session-reschedule-week]'].emit('click');
|
||||||
await selectors['#today-week-reschedule-days'].children[0].emit('click');
|
await selectors['#today-week-reschedule-days'].children[0].emit('click');
|
||||||
|
|
@ -376,7 +379,11 @@ if(__FAIL_MOVE__){
|
||||||
await confirming;
|
await confirming;
|
||||||
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open}));
|
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open}));
|
||||||
"""
|
"""
|
||||||
return run_controller(scenario.replace("__FAIL_MOVE__", failure))
|
return run_controller(
|
||||||
|
scenario.replace("__FAIL_MOVE__", failure).replace(
|
||||||
|
"__FAIL_REFRESH__", refresh_failure
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_dialog_closes_as_soon_as_a_valid_move_is_confirmed():
|
def test_reschedule_dialog_closes_as_soon_as_a_valid_move_is_confirmed():
|
||||||
|
|
@ -393,6 +400,13 @@ def test_reschedule_dialog_reopens_with_retry_state_when_confirm_fails():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_dialog_stays_closed_when_refresh_fails_after_confirm_succeeds():
|
||||||
|
assert run_mounted_confirmation(fail_refresh=True) == {
|
||||||
|
"openWhileSaving": False,
|
||||||
|
"openAfterSettled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
||||||
index = INDEX.read_text()
|
index = INDEX.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
|
def test_workspace_bootstrap_demand_loads_optional_features_after_work_core():
|
||||||
result = run_bootstrap("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''};
|
const status={textContent:''};
|
||||||
const document={
|
const document={
|
||||||
|
|
@ -33,11 +33,14 @@ const document={
|
||||||
};
|
};
|
||||||
const requested=[];
|
const requested=[];
|
||||||
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
||||||
await loadWorkspace({document,createLoader});
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
const before=requested.slice();
|
||||||
|
await lifecycle.hydrateWorkspace?.();
|
||||||
|
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"requested": [
|
"before": ["work-core:feature-work-core-123.js"],
|
||||||
|
"after": [
|
||||||
"work-core:feature-work-core-123.js",
|
"work-core:feature-work-core-123.js",
|
||||||
"today-timer:feature-workspace-abc.js",
|
"today-timer:feature-workspace-abc.js",
|
||||||
"planning:feature-planning-def.js",
|
"planning:feature-planning-def.js",
|
||||||
|
|
@ -46,7 +49,65 @@ console.log(JSON.stringify({requested,status:status.textContent}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate():
|
def test_workspace_bootstrap_hydrates_once_and_replays_dependent_mobile_action():
|
||||||
|
result = run_bootstrap("""
|
||||||
|
const listeners={}; const requested=[];
|
||||||
|
const document={
|
||||||
|
querySelector(selector) {
|
||||||
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||||
|
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
||||||
|
},
|
||||||
|
addEventListener(name,callback,capture){listeners[name]={callback,capture};},
|
||||||
|
removeEventListener(name,callback,capture){
|
||||||
|
if(listeners[name]?.callback===callback && listeners[name]?.capture===capture) delete listeners[name];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createLoader=()=>({load:async name=>{
|
||||||
|
requested.push(name);
|
||||||
|
if(name==='today-timer') setImmediate(()=>{
|
||||||
|
actionReady=true;
|
||||||
|
lifecycle.markWorkspaceReady?.();
|
||||||
|
});
|
||||||
|
}});
|
||||||
|
let lifecycle=await loadWorkspace({document,createLoader});
|
||||||
|
let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false;
|
||||||
|
const target={
|
||||||
|
closest(selector){return selector.includes(':not([data-mobile-task="work"])') ? null : this;},
|
||||||
|
click(){replayed++; replayedReady=actionReady;},
|
||||||
|
};
|
||||||
|
await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}});
|
||||||
|
console.log(JSON.stringify({requested,prevented,stopped,replayed,replayedReady,listening:Boolean(listeners.click)}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"requested": ["work-core", "today-timer", "planning"],
|
||||||
|
"prevented": 1,
|
||||||
|
"stopped": 1,
|
||||||
|
"replayed": 1,
|
||||||
|
"replayedReady": True,
|
||||||
|
"listening": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_bootstrap_hydrates_immediately_for_deep_link():
|
||||||
|
result = run_bootstrap("""
|
||||||
|
const requested=[];
|
||||||
|
const document={
|
||||||
|
querySelector(selector) {
|
||||||
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||||
|
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
||||||
|
},
|
||||||
|
addEventListener(){},
|
||||||
|
};
|
||||||
|
const window={location:{hash:'#/my-work/today'},addEventListener(){},removeEventListener(){}};
|
||||||
|
const createLoader=()=>({load:async name=>{requested.push(name);}});
|
||||||
|
const lifecycle=await loadWorkspace({document,window,createLoader});
|
||||||
|
await lifecycle.deepLinkReady;
|
||||||
|
console.log(JSON.stringify({requested}));
|
||||||
|
""")
|
||||||
|
assert result == {"requested": ["work-core", "today-timer", "planning"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_bootstrap_returns_after_work_core_before_optional_features_hydrate():
|
||||||
result = run_bootstrap("""
|
result = run_bootstrap("""
|
||||||
const document={querySelector(selector) {
|
const document={querySelector(selector) {
|
||||||
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||||
|
|
@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{
|
||||||
}});
|
}});
|
||||||
const lifecycle=await loadWorkspace({document,createLoader});
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
const returned=requested.slice();
|
const returned=requested.slice();
|
||||||
|
const hydration=lifecycle.hydrateWorkspace();
|
||||||
releases['today-timer'](); releases.planning();
|
releases['today-timer'](); releases.planning();
|
||||||
await lifecycle.optionalReady;
|
await hydration;
|
||||||
console.log(JSON.stringify({returned,settled:requested}));
|
console.log(JSON.stringify({returned,settled:requested}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"returned": ["work-core", "today-timer", "planning"],
|
"returned": ["work-core"],
|
||||||
"settled": ["work-core", "today-timer", "planning"],
|
"settled": ["work-core", "today-timer", "planning"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +143,8 @@ const document={querySelector(selector) {
|
||||||
}};
|
}};
|
||||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
|
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
|
||||||
const schedule=callback=>{callback();};
|
const schedule=callback=>{callback();};
|
||||||
await loadWorkspace({document,createLoader,schedule});
|
const lifecycle=await loadWorkspace({document,createLoader,schedule});
|
||||||
|
await lifecycle.hydrateWorkspace();
|
||||||
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {"attempts": 4, "status": "", "retryHidden": True}
|
assert result == {"attempts": 4, "status": "", "retryHidden": True}
|
||||||
|
|
@ -107,7 +170,7 @@ await Promise.all([first,second,loading]);
|
||||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 5,
|
"attempts": 3,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"offered": {
|
"offered": {
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
|
|
@ -151,7 +214,7 @@ await loading;
|
||||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 5,
|
"attempts": 3,
|
||||||
"waiting": True,
|
"waiting": True,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"status": "",
|
"status": "",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user