Compare commits
52 Commits
v0.1.0-rc.
...
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 | |||
| dbabd8640b | |||
| 890fd57531 | |||
| b0b051184b | |||
| 2432d4fc03 | |||
| c97383fccb | |||
| 89b1ad1f08 | |||
| 0328e197ac | |||
| 7a09452bef | |||
| 4b4637fadb | |||
| 977493907e | |||
| 9381ff9d84 | |||
| c2b7ce67d8 | |||
| 43b0216bda | |||
| ff469d96d1 | |||
| 94c18c7a35 | |||
| a77a73a269 | |||
| b303683ba1 |
28
README.md
28
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
|
||||
`.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
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -859,7 +880,10 @@ Authenticated release producers use `POST /api/v1/human-gates/intake` with an
|
|||
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
|
||||
binds each queue to the upstream principal ID and login, including its offline
|
||||
browser cache. Review decisions carry `expected_revision`, a stable idempotency
|
||||
key across network retries, and return durable receipts. If producer evidence
|
||||
key across network retries and mobile app restarts, and return durable receipts.
|
||||
An interrupted decision response is restored as **Decision outcome unknown**;
|
||||
the operator verifies the exact persisted operation instead of creating a second
|
||||
decision. The client clears that operation only after recovering a receipt. If producer evidence
|
||||
changes after a release or hold, the update reopens the exact hash for a new
|
||||
revision-checked decision instead of silently retaining the old outcome.
|
||||
New hashes mark older pending candidates `superseded` without removing their audit
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ The immutable identity is authenticated account + `source` + `project` + `candid
|
|||
|
||||
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
|
||||
|
||||
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
|
||||
|
||||
## Privacy-safe notification contract
|
||||
|
||||
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ function createContextPoller({
|
|||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
|
||||
} catch (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-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.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; }
|
||||
@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)); }
|
||||
.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;
|
||||
|
|
@ -1515,6 +1518,19 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; }
|
||||
.mobile-queue-next p, .mobile-queue-group h3 { margin:0; }
|
||||
.mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; }
|
||||
.mobile-queue-priority { margin-top:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-queue-priority > summary { min-height:44px; display:flex; align-items:center; padding:0 12px; cursor:pointer; font-weight:700; }
|
||||
.mobile-queue-priority > p { margin:0; padding:0 12px 10px; }
|
||||
.mobile-queue-priority-list { display:grid; gap:6px; padding:0 8px; }
|
||||
.mobile-queue-priority-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; min-width:0; padding:6px 4px; border-top:1px solid #233f61; }
|
||||
.mobile-queue-priority-row > span:first-child { min-width:0; overflow-wrap:anywhere; font-weight:700; }
|
||||
.mobile-queue-priority-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; }
|
||||
.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 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 h3 { font-size:1rem; }
|
||||
.mobile-queue-all { margin-top:16px; }
|
||||
|
|
@ -1533,12 +1549,16 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
|
||||
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.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 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="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; }
|
||||
|
|
@ -1626,5 +1646,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
|
||||
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
|
||||
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
|
||||
.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px}
|
||||
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
|
||||
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}
|
||||
|
|
@ -112,7 +112,10 @@
|
|||
}
|
||||
let queueCounts = {};
|
||||
let preparationItems = {};
|
||||
let offlineWorkMode = false;
|
||||
let renderMobileQueuePresentation = () => {};
|
||||
let mobileQueuePriority = null;
|
||||
let mobileRecentWork = { record:() => false, render:() => 0 };
|
||||
const followingQueue = attachFollowing(item => {
|
||||
searchPreviewReturnKind = 'following';
|
||||
return searchPreview.open(item);
|
||||
|
|
@ -123,12 +126,14 @@
|
|||
queueCounts.followingUnavailable = false;
|
||||
preparationItems.following = items.filter(item => item.has_unseen_change === true);
|
||||
renderMobileQueuePresentation();
|
||||
mobileTaskDock.updateQueues(queueCounts);
|
||||
mobileStartDay.render();
|
||||
},
|
||||
onStatus:status => {
|
||||
if (status === 'loading') return;
|
||||
queueCounts.followingUnavailable = status === 'error';
|
||||
renderMobileQueuePresentation();
|
||||
mobileTaskDock.updateQueues(queueCounts);
|
||||
mobileStartDay.render();
|
||||
},
|
||||
onReviewComplete:() => mobileStartDay.completePhase('following'),
|
||||
|
|
@ -179,6 +184,13 @@
|
|||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: announceWork,
|
||||
getCounts: () => queueCounts,
|
||||
isOnline: () => !offlineWorkMode,
|
||||
getRoutineOrder: () => mobileQueuePriority?.getOrder(),
|
||||
getPreparation: () => {
|
||||
const briefing = mobileStartDay.briefing();
|
||||
return {...briefing, active:mobileStartDay.state().active};
|
||||
},
|
||||
openPreparation: () => mobileStartDay.startNext(),
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
rows: Object.fromEntries(
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
||||
|
|
@ -190,7 +202,6 @@
|
|||
activeSection: qs('#mobile-queue-active-list').parentElement,
|
||||
});
|
||||
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
|
||||
renderMobileQueuePresentation();
|
||||
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
|
||||
function openMobileStartDay() {
|
||||
followingQueue.load().catch(() => {});
|
||||
|
|
@ -199,7 +210,7 @@
|
|||
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
||||
qs('#mobile-start-day-action').focus();
|
||||
qs('#mobile-queue-next-action').focus();
|
||||
}
|
||||
const mobileFirstTask = createMobileFirstTask({
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
|
|
@ -238,17 +249,20 @@
|
|||
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
|
||||
const sheet = qs('#mobile-queue-sheet');
|
||||
if (!sheet.open) sheet.showModal();
|
||||
qs('#mobile-start-day-action').focus();
|
||||
qs('#mobile-queue-next-action').focus();
|
||||
},
|
||||
elements: {
|
||||
summary: qs('#mobile-start-day-summary'),
|
||||
phases: qs('#mobile-start-day-phases'),
|
||||
action: qs('#mobile-start-day-action'),
|
||||
finish: qs('#finish-mobile-start-day'),
|
||||
},
|
||||
});
|
||||
mobileStartDay.start();
|
||||
qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish());
|
||||
renderMobileQueuePresentation();
|
||||
qs('#finish-mobile-start-day').addEventListener('click', () => {
|
||||
mobileStartDay.finish();
|
||||
renderMobileQueuePresentation();
|
||||
});
|
||||
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
|
||||
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
|
||||
mobileStartDay.render();
|
||||
|
|
@ -300,7 +314,7 @@
|
|||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
queues: () => refreshTomorrowQueueSummary(),
|
||||
queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
|
||||
},
|
||||
observe(callback, overlays) {
|
||||
const observer = new MutationObserver(callback);
|
||||
|
|
@ -345,7 +359,7 @@
|
|||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||
let liveMode = true;
|
||||
let offlineWorkMode = false;
|
||||
let initialAccountRecovery = Promise.resolve(false);
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.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'];
|
||||
|
|
@ -418,6 +432,47 @@
|
|||
let planningOwnerLogin = '';
|
||||
let planningOwnerAccountKey = '';
|
||||
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({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
fetchJson: fetchReviewJson,
|
||||
document,
|
||||
list: qs('#mobile-queue-priority-list'),
|
||||
resetButton: qs('#reset-mobile-queue-priority'),
|
||||
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: {
|
||||
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
|
||||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
},
|
||||
onChange: () => {
|
||||
renderMobileQueuePresentation();
|
||||
},
|
||||
});
|
||||
mobileQueuePriority.start();
|
||||
mobileQueuePriority.startLifecycle({window, document});
|
||||
let rR = null;
|
||||
function rRC() {
|
||||
if (rR) return rR;
|
||||
|
|
@ -650,6 +705,7 @@
|
|||
if (!response.ok) {
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
|
||||
error.code = payload.detail?.code;
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
|
|
@ -704,6 +760,15 @@
|
|||
qs('#human-gates-status').textContent = error.message;
|
||||
}));
|
||||
qs('#human-gate-detail').addEventListener('click', event => {
|
||||
const recovery = event.target.closest('[data-gate-recover]');
|
||||
if (recovery) {
|
||||
recovery.disabled = true;
|
||||
humanGates.recoverDecision().catch(error => {
|
||||
qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.';
|
||||
recovery.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
|
||||
if (!decision) return;
|
||||
humanGates.submitDecision(decision).catch(error => {
|
||||
|
|
@ -2017,6 +2082,8 @@
|
|||
qs('#my-work-action-status').textContent = '';
|
||||
closeOpenWorkSheets();
|
||||
await openRoutedWorkSection(item);
|
||||
mobileRecentWork.record(item);
|
||||
mobileRecentWork.setCurrent(item);
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||||
},
|
||||
|
|
@ -2047,6 +2114,7 @@
|
|||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||
|
||||
function closeOpenWorkSheets() {
|
||||
mobileRecentWork.setCurrent(null);
|
||||
issueVoiceReply.cancel();
|
||||
pullVoiceReply.cancel();
|
||||
updateVoiceReply.cancel();
|
||||
|
|
@ -3531,9 +3599,12 @@
|
|||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||||
counts.gate = queueCounts.gate;
|
||||
counts.gateUnavailable = queueCounts.gateUnavailable;
|
||||
counts.following = queueCounts.following;
|
||||
counts.followingUnavailable = queueCounts.followingUnavailable;
|
||||
preparationItems = {
|
||||
delivery:draftInbox.partition(lastDrafts).deliveries,
|
||||
gate:preparationItems.gate || [],
|
||||
following:preparationItems.following || [],
|
||||
agenda:agendaMyWork(activeMyWork),
|
||||
attention:activeMyWork.filter(item => item.needs_attention),
|
||||
update:activeMyWork.filter(item => item.has_update),
|
||||
|
|
@ -5598,6 +5669,13 @@
|
|||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
planningOwnerLogin = activeFlushLogin;
|
||||
planningOwnerAccountKey = snapshot.context.user?.id ?
|
||||
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
||||
mobileQueuePriority.render();
|
||||
renderMobileQueuePresentation();
|
||||
void mobileRecentWork.load();
|
||||
void mobileQueuePriority.load();
|
||||
void refreshPhotoDraftInbox();
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
|
|
@ -5617,7 +5695,10 @@
|
|||
if (planningOwnerLogin) {
|
||||
syncPendingTomorrow();
|
||||
todaySync.migrate(todayWork.read());
|
||||
todaySync.flush();
|
||||
initialAccountRecovery = Promise.all([
|
||||
initialAccountRecovery,
|
||||
todaySync.flush(),
|
||||
]).then(() => true);
|
||||
laterSync.migrate(laterWork.read());
|
||||
laterSync.flush();
|
||||
}
|
||||
|
|
@ -7916,6 +7997,7 @@
|
|||
}
|
||||
function setOfflineWorkMode(value) {
|
||||
offlineWorkMode = value;
|
||||
renderMobileQueuePresentation();
|
||||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||||
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
|
||||
if (value) {
|
||||
|
|
@ -7928,6 +8010,8 @@
|
|||
if (!saved) return false;
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
mobileQueuePriority.render();
|
||||
renderMobileQueuePresentation();
|
||||
restoreReleaseReceipt();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
|
||||
|
|
@ -8420,7 +8504,8 @@
|
|||
});
|
||||
let pushControllerReady = Promise.resolve(null);
|
||||
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');
|
||||
const controller = createPushNotifications({
|
||||
control:qs('#push-updates'),
|
||||
|
|
@ -8544,6 +8629,20 @@
|
|||
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
|
||||
}
|
||||
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) {
|
||||
const progressiveItem = lastMyWork.find(item =>
|
||||
item.repository === progressiveWorkHandoff.openWork.repository &&
|
||||
|
|
@ -8562,4 +8661,8 @@
|
|||
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
await initialAccountRecovery;
|
||||
if (planningOwnerLogin) await todaySync.flush();
|
||||
await timerView.restore(Promise.resolve(true));
|
||||
workspaceLifecycle.markWorkspaceReady?.();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ function createHumanGates(options = {}) {
|
|||
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
|
||||
const progressKey = item => 'stackchain.human-gate-review.v1:' +
|
||||
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
|
||||
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
|
||||
String(getAccountKey() || '').trim().toLowerCase();
|
||||
const setText = (node, value) => { if (node) node.textContent = value; };
|
||||
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
|
||||
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
|
||||
|
|
@ -65,6 +67,26 @@ function createHumanGates(options = {}) {
|
|||
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function restorePendingDecision(item = null) {
|
||||
if (!getLogin()) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
|
||||
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
|
||||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
|
||||
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
|
||||
return value;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function savePendingDecision(value) {
|
||||
storage.setItem(decisionStorageKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearPendingDecision(item) {
|
||||
if (!restorePendingDecision(item)) return;
|
||||
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
|
||||
}
|
||||
|
||||
function valuesFromDetail() {
|
||||
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
return {
|
||||
|
|
@ -215,6 +237,7 @@ function createHumanGates(options = {}) {
|
|||
return;
|
||||
}
|
||||
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
|
||||
const pendingDecision = restorePendingDecision(item);
|
||||
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
|
||||
const checks = (item.checks || []).map(check =>
|
||||
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
|
||||
|
|
@ -236,11 +259,14 @@ function createHumanGates(options = {}) {
|
|||
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
|
||||
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
|
||||
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
|
||||
'<div class="human-gate-decision-tray"><div class="human-gate-decision-state">' +
|
||||
'<strong data-gate-readiness>0 of 3 confirmations complete</strong>' +
|
||||
'<span data-gate-error role="alert" hidden></span></div>' +
|
||||
'<div class="human-gate-decision-actions"><button type="button" data-gate-decision="hold">Hold & next</button>' +
|
||||
'<button type="button" data-gate-decision="release">Release & next</button></div></div></article>'
|
||||
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
|
||||
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
|
||||
' response was interrupted. Verify it before making another decision.</span>' :
|
||||
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
|
||||
'<div class="human-gate-decision-actions">' + (pendingDecision ?
|
||||
'<button type="button" data-gate-recover>Verify decision</button>' :
|
||||
'<button type="button" data-gate-decision="hold">Hold & next</button><button type="button" data-gate-decision="release">Release & next</button>') +
|
||||
'</div></div></article>'
|
||||
);
|
||||
updateReadiness();
|
||||
}
|
||||
|
|
@ -266,6 +292,16 @@ function createHumanGates(options = {}) {
|
|||
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
||||
if (!live) throw new Error('Human Gates response is invalid.');
|
||||
queue = { pending_count: live.pending_count, items: live.items.slice() };
|
||||
const interrupted = restorePendingDecision();
|
||||
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
|
||||
const recoveryItem = {...interrupted.item, decision_recovery:true};
|
||||
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
|
||||
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
|
||||
else {
|
||||
queue.items.unshift(recoveryItem);
|
||||
queue.pending_count += 1;
|
||||
}
|
||||
}
|
||||
save(queue);
|
||||
render();
|
||||
publish({available:true, authoritative:true});
|
||||
|
|
@ -311,6 +347,8 @@ function createHumanGates(options = {}) {
|
|||
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
|
||||
function idempotencyKey(item, decision, payload) {
|
||||
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
|
||||
const pending = restorePendingDecision(item);
|
||||
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
|
||||
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
|
||||
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
|
||||
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
|
||||
|
|
@ -318,6 +356,30 @@ function createHumanGates(options = {}) {
|
|||
return { operation, key };
|
||||
}
|
||||
|
||||
function completeDecision(item, decision, operation, receipt) {
|
||||
decisionKeys.delete(operation);
|
||||
clearPendingDecision(item);
|
||||
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
|
||||
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
|
||||
queue.pending_count = Math.max(0, queue.pending_count - 1);
|
||||
save(queue); render();
|
||||
publish({available:true, authoritative:true, decision:true});
|
||||
reviewIndex += 1;
|
||||
const next = current();
|
||||
if (next) reviewNext(); else renderDetail(null);
|
||||
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
|
||||
return { receipt, next };
|
||||
}
|
||||
|
||||
async function postDecision(item, pending) {
|
||||
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
|
||||
body: JSON.stringify(pending.payload),
|
||||
});
|
||||
return completeDecision(item, pending.decision, pending.operation, receipt);
|
||||
}
|
||||
|
||||
function decisionError(message, selector) {
|
||||
const error = new Error(message);
|
||||
error.targetSelector = selector;
|
||||
|
|
@ -351,22 +413,19 @@ function createHumanGates(options = {}) {
|
|||
if (decisionFlight) return decisionFlight;
|
||||
const operation = (async () => {
|
||||
const decisionKey = idempotencyKey(item, decision, payload);
|
||||
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
decisionKeys.delete(decisionKey.operation);
|
||||
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
|
||||
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
|
||||
queue.pending_count = Math.max(0, queue.pending_count - 1);
|
||||
save(queue); render();
|
||||
publish({available:true, authoritative:true, decision:true});
|
||||
reviewIndex += 1;
|
||||
const next = current();
|
||||
if (next) reviewNext(); else renderDetail(null);
|
||||
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
|
||||
return { receipt, next };
|
||||
const pending = {
|
||||
gate_id:item.id, revision:item.revision, decision, payload,
|
||||
operation:decisionKey.operation, idempotency_key:decisionKey.key,
|
||||
item:JSON.parse(JSON.stringify(item)),
|
||||
};
|
||||
savePendingDecision(pending);
|
||||
try {
|
||||
return await postDecision(item, pending);
|
||||
} catch (error) {
|
||||
renderDetail(item);
|
||||
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
decisionFlight = operation;
|
||||
try {
|
||||
|
|
@ -376,6 +435,20 @@ function createHumanGates(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
async function recoverDecision() {
|
||||
const item = current();
|
||||
if (!item) throw new Error('No gate is selected.');
|
||||
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const pending = restorePendingDecision(item);
|
||||
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
|
||||
if (decisionFlight) return decisionFlight;
|
||||
const operation = postDecision(item, pending);
|
||||
decisionFlight = operation;
|
||||
try { return await operation; }
|
||||
finally { if (decisionFlight === operation) decisionFlight = null; }
|
||||
}
|
||||
|
||||
async function submitDecision(decision) {
|
||||
const values = valuesFromDetail();
|
||||
updateReadiness(values);
|
||||
|
|
@ -425,7 +498,7 @@ function createHumanGates(options = {}) {
|
|||
|
||||
return {
|
||||
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
|
||||
decideAndNext, submitDecision, current, saveProgress,
|
||||
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
|
||||
setOnChange(callback) { onChange = callback; },
|
||||
restoreCached: restore,
|
||||
snapshot: () => JSON.parse(JSON.stringify(queue)),
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,7 @@
|
|||
<div class="small" id="issue-sheet-key"></div>
|
||||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||||
</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>
|
||||
</div>
|
||||
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
||||
|
|
@ -1553,6 +1554,7 @@
|
|||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
</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">
|
||||
|
|
@ -1676,6 +1678,7 @@
|
|||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<h3 id="review-sheet-title">Pull request review</h3>
|
||||
</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>
|
||||
</div>
|
||||
<nav class="mobile-review-detail-nav" aria-label="Review sections">
|
||||
|
|
@ -2177,13 +2181,36 @@
|
|||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
|
||||
</div>
|
||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||
</section>
|
||||
<section class="mobile-queue-next" aria-labelledby="mobile-queue-next-heading">
|
||||
<p class="small muted" id="mobile-queue-next-heading">Next up</p>
|
||||
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||
</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">
|
||||
<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>
|
||||
<div id="mobile-queue-priority-list" class="mobile-queue-priority-list"></div>
|
||||
<div class="mobile-queue-priority-footer">
|
||||
<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>
|
||||
</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>
|
||||
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
||||
<h3 id="mobile-queue-active-heading">Active now</h3>
|
||||
<div class="mobile-queue-list" id="mobile-queue-active-list"></div>
|
||||
|
|
@ -2403,6 +2430,8 @@
|
|||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-first-task.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-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
|
|
|
|||
|
|
@ -8,44 +8,68 @@
|
|||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['gate', 'Review Human Gates'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
const labels = {
|
||||
delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
|
||||
today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
|
||||
following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
|
||||
later: 'Start Later', draft: 'Open Drafts',
|
||||
};
|
||||
const criticalQueueNames = ['delivery', 'gate'];
|
||||
const defaultRoutineOrder = [
|
||||
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
||||
];
|
||||
const activeQueueNames = continuation.map(([name]) => name).concat(['following', 'authored']);
|
||||
const onlineOnlyQueues = new Set(['delivery', 'gate']);
|
||||
const allQueues = [
|
||||
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
|
||||
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
|
||||
];
|
||||
|
||||
function routineOrder() {
|
||||
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
|
||||
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
|
||||
new Set(proposed).size !== defaultRoutineOrder.length ||
|
||||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
|
||||
return proposed.slice();
|
||||
}
|
||||
|
||||
function activeQueueNames() {
|
||||
return criticalQueueNames.concat(routineOrder());
|
||||
}
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
const online = options.isOnline ? options.isOnline() : true;
|
||||
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
|
||||
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
|
||||
);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function adaptiveRecommendation() {
|
||||
const preparation = options.getPreparation ? options.getPreparation() : null;
|
||||
if (preparation && (preparation.active || Number(preparation.total) > 0)) {
|
||||
const prefix = preparation.active ? 'Resume preparation · ' : 'Start day · ';
|
||||
return {name: 'prepare', count: Math.max(0, Number(preparation.total) || 0), label: prefix + preparation.label};
|
||||
}
|
||||
return recommend();
|
||||
}
|
||||
|
||||
function presentation() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const active = activeQueueNames
|
||||
const names = activeQueueNames();
|
||||
const active = names
|
||||
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
|
||||
.filter(item => item.count > 0);
|
||||
activeQueueNames.forEach(name => {
|
||||
names.forEach(name => {
|
||||
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
|
||||
active.push({name, unavailable: true});
|
||||
}
|
||||
});
|
||||
return {
|
||||
nextUp: recommend(),
|
||||
nextUp: adaptiveRecommendation(),
|
||||
active,
|
||||
planning: ['today', 'tomorrow', 'week'],
|
||||
all: allQueues.slice(),
|
||||
|
|
@ -60,7 +84,8 @@
|
|||
if (options.nextAction) {
|
||||
options.nextAction.textContent = view.nextUp.label;
|
||||
options.nextAction.dataset.queue = view.nextUp.name;
|
||||
options.nextAction.setAttribute('aria-label', 'Next up: ' + view.nextUp.label);
|
||||
const prefix = view.nextUp.name === 'prepare' ? 'Start or continue: ' : 'Next up: ';
|
||||
options.nextAction.setAttribute('aria-label', prefix + view.nextUp.label);
|
||||
}
|
||||
active.forEach(item => {
|
||||
const row = options.rows?.[item.name];
|
||||
|
|
@ -103,7 +128,11 @@
|
|||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
const next = adaptiveRecommendation();
|
||||
if (next.name === 'prepare') {
|
||||
options.openPreparation();
|
||||
return 'prepare';
|
||||
}
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
|
|
@ -111,5 +140,5 @@
|
|||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, presentation, renderPresentation, continueWork };
|
||||
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
|
||||
});
|
||||
|
|
|
|||
340
frontend/mobile-queue-priority.js
Normal file
340
frontend/mobile-queue-priority.js
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueuePriority = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
|
||||
const DEFAULT_ORDER = [
|
||||
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
||||
];
|
||||
const storage = options.storage;
|
||||
const getLogin = options.getLogin || (() => '');
|
||||
const fetchJson = options.fetchJson;
|
||||
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
||||
const labels = options.labels || {};
|
||||
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() {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function valid(order) {
|
||||
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
|
||||
new Set(order).size === DEFAULT_ORDER.length &&
|
||||
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
|
||||
}
|
||||
|
||||
function read() {
|
||||
const accountKey = key();
|
||||
if (!accountKey || !storage) return fresh();
|
||||
if (memory?.key === accountKey) return memory.value;
|
||||
let value = fresh();
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
|
||||
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
|
||||
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
|
||||
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 persist(value) {
|
||||
const accountKey = key();
|
||||
if (!accountKey || !storage) return false;
|
||||
memory = {key:accountKey, value};
|
||||
try {
|
||||
storage.setItem(accountKey, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
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) {
|
||||
const order = getOrder();
|
||||
const index = order.indexOf(name);
|
||||
const next = index + (Number(delta) < 0 ? -1 : 1);
|
||||
if (index < 0 || next < 0 || next >= order.length) return order;
|
||||
[order[index], order[next]] = [order[next], order[index]];
|
||||
save(order);
|
||||
return order.slice();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const order = DEFAULT_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;
|
||||
}
|
||||
|
||||
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) {
|
||||
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!options.list || !documentRef) return getOrder();
|
||||
const order = getOrder();
|
||||
const signedIn = Boolean(key());
|
||||
const rows = order.map((name, index) => {
|
||||
const row = documentRef.createElement('div');
|
||||
row.setAttribute('data-queue-priority', name);
|
||||
row.setAttribute('class', 'mobile-queue-priority-row');
|
||||
const label = documentRef.createElement('span');
|
||||
label.textContent = displayName(name);
|
||||
const controls = documentRef.createElement('span');
|
||||
controls.setAttribute('class', 'mobile-queue-priority-controls');
|
||||
const earlier = documentRef.createElement('button');
|
||||
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
|
||||
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
|
||||
earlier.disabled = !signedIn || index === 0;
|
||||
earlier.addEventListener('click', () => {
|
||||
move(name, -1); render();
|
||||
});
|
||||
const later = documentRef.createElement('button');
|
||||
later.textContent = 'Later'; later.setAttribute('type', 'button');
|
||||
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
|
||||
later.disabled = !signedIn || index === order.length - 1;
|
||||
later.addEventListener('click', () => {
|
||||
move(name, 1); render();
|
||||
});
|
||||
controls.append(earlier, later); row.append(label, controls);
|
||||
return row;
|
||||
});
|
||||
options.list.replaceChildren(...rows);
|
||||
if (options.resetButton) options.resetButton.disabled = !signedIn;
|
||||
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
|
||||
return order;
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.resetButton?.addEventListener('click', () => {
|
||||
reset(); render();
|
||||
});
|
||||
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
|
||||
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
|
||||
return render();
|
||||
}
|
||||
|
||||
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};
|
||||
});
|
||||
|
|
@ -185,14 +185,16 @@
|
|||
options.elements.phases.textContent = current.phases.length ?
|
||||
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
||||
'All urgent queues reviewed';
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
if (options.elements.action) {
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
}
|
||||
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
||||
return current;
|
||||
}
|
||||
|
||||
function start() {
|
||||
render();
|
||||
if (options.elements) options.elements.action.addEventListener('click', startNext);
|
||||
if (options.elements?.action) options.elements.action.addEventListener('click', startNext);
|
||||
}
|
||||
|
||||
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
||||
|
|
|
|||
|
|
@ -70,9 +70,10 @@
|
|||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda',
|
||||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
|
|
@ -97,9 +98,9 @@
|
|||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda delivery gate attention update filed later draft'.split(' ');
|
||||
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'following', 'filed', 'authored', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
|
|
@ -107,6 +108,9 @@
|
|||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
options.queueRows?.following?.setAttribute(
|
||||
'aria-label', 'Following, ' + normalized.following + ' unseen changes'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
|
|
@ -127,7 +131,9 @@
|
|||
+ ', Human Gates ' + normalized.gate
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Following ' + normalized.following
|
||||
+ ', Filed ' + normalized.filed
|
||||
+ ', My PRs ' + normalized.authored
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ function createProgressiveHumanGates(options = {}) {
|
|||
if (card) controller.select(card.dataset.humanGateId);
|
||||
});
|
||||
nodes.detail?.addEventListener?.('click', event => {
|
||||
const recovery = event.target?.closest?.('[data-gate-recover]');
|
||||
if (recovery) {
|
||||
recovery.disabled = true;
|
||||
controller.recoverDecision().catch(error => {
|
||||
showError(error);
|
||||
recovery.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
|
||||
if (!decision) return;
|
||||
controller.submitDecision(decision).catch(error => {
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@
|
|||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
source_branch_deleted: 'Source branch deleted',
|
||||
release_rollback_prepared: 'Release rollback prepared',
|
||||
ci_job_retried: 'CI job retried',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v149';
|
||||
const CACHE = 'stackchain-dashboard-shell-v150';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
@ -270,6 +270,8 @@ const SHELL = [
|
|||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-recent-work.js',
|
||||
BASE + 'static/mobile-queue-priority.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
|
|
@ -300,6 +302,8 @@ const SHELL = [
|
|||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
const DEMAND_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
|
@ -1026,7 +1030,8 @@ self.addEventListener('fetch', event => {
|
|||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -489,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
|||
const snapshot = timer.snapshot();
|
||||
breakView?.render();
|
||||
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 => {
|
||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -338,8 +338,18 @@ function mountTodayWeekReschedule({
|
|||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
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{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
|
|
@ -347,11 +357,7 @@ function mountTodayWeekReschedule({
|
|||
}
|
||||
await continueToday();
|
||||
}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;
|
||||
announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ async function loadWorkspace({
|
|||
const failed = new Set();
|
||||
const recoveries = new Map();
|
||||
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 => {
|
||||
attempts[name] += 1;
|
||||
|
|
@ -63,6 +72,8 @@ async function loadWorkspace({
|
|||
retryButton?.addEventListener?.('click', retryFailed);
|
||||
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||
window?.addEventListener('online', handleOnline);
|
||||
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
|
||||
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
|
||||
|
||||
try {
|
||||
await retryOnce('work-core');
|
||||
|
|
@ -73,23 +84,56 @@ async function loadWorkspace({
|
|||
}
|
||||
hideRecovery();
|
||||
|
||||
const optional = ['today-timer', 'planning'].map(async name => {
|
||||
try {
|
||||
await retryOnce(name);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
failed.add(name);
|
||||
showRecovery();
|
||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
||||
}
|
||||
});
|
||||
const optionalReady = Promise.all(optional).then(() => {
|
||||
hideRecovery();
|
||||
return true;
|
||||
});
|
||||
let optionalReady = null;
|
||||
const hydrateWorkspace = () => {
|
||||
if (optionalReady) return optionalReady;
|
||||
let resolveOptional;
|
||||
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
|
||||
const optional = ['today-timer', 'planning'].map(async name => {
|
||||
try {
|
||||
await retryOnce(name);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
failed.add(name);
|
||||
showRecovery();
|
||||
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 {
|
||||
optionalReady,
|
||||
hydrateWorkspace,
|
||||
deepLinkReady,
|
||||
workspaceReady,
|
||||
markWorkspaceReady,
|
||||
serviceWorkerReady,
|
||||
get optionalReady() { return hydrateWorkspace(); },
|
||||
retryFeature(name) {
|
||||
if (!failed.has(name)) return Promise.resolve(true);
|
||||
return retryFailed().then(() => !failed.has(name));
|
||||
|
|
|
|||
|
|
@ -78,6 +78,12 @@ STORES = (
|
|||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
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(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
|
|
|
|||
|
|
@ -236,7 +236,11 @@ async def revoke_all_sessions() -> None:
|
|||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
return await asyncio.to_thread(
|
||||
_session_store().list_active,
|
||||
session.session_id,
|
||||
principal_id=session.principal_id,
|
||||
)
|
||||
|
||||
|
||||
async def session_management_id(session: Session) -> str:
|
||||
|
|
|
|||
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": (
|
||||
"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-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/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",
|
||||
|
|
@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
|||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
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(
|
||||
" 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"
|
||||
+ "".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(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
|
|
|||
322
src/main.py
322
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.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
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.unfiled_draft_store import (
|
||||
UnfiledDraftConflict,
|
||||
|
|
@ -496,6 +498,15 @@ async def _upstream_identity() -> tuple[int, str]:
|
|||
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):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
||||
|
|
@ -595,6 +606,7 @@ StepUpAction = Literal[
|
|||
"merge_pull",
|
||||
"delete_source_branch",
|
||||
"prepare_release_rollback",
|
||||
"retry_ci_job",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"delete_comment",
|
||||
|
|
@ -956,6 +968,23 @@ class SavedSearchCollection(BaseModel):
|
|||
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):
|
||||
repository: str = Field(
|
||||
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):
|
||||
response = await call_next(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/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
|
|
@ -1929,6 +1958,7 @@ async def decide_human_gate(
|
|||
target=gate_id,
|
||||
)
|
||||
login = await _human_gate_login(request)
|
||||
principal_id = int(login.partition(":")[0])
|
||||
journal = _security_event_store()
|
||||
operation_id = None
|
||||
try:
|
||||
|
|
@ -1939,6 +1969,7 @@ async def decide_human_gate(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"human_gate_decision",
|
||||
principal_id=principal_id,
|
||||
method=payload.decision,
|
||||
target=gate_id,
|
||||
)
|
||||
|
|
@ -2062,6 +2093,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
|||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
principal_id=principal_id,
|
||||
method="token",
|
||||
device_label=payload.device_label,
|
||||
target="dashboard",
|
||||
|
|
@ -2099,12 +2131,16 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
|||
|
||||
@app.get("/api/v1/security-events")
|
||||
async def list_security_events(
|
||||
request: Request,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
cursor: int | None = Query(default=None, ge=1),
|
||||
):
|
||||
try:
|
||||
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(
|
||||
_login_attempt_store().list_alerts, limit=24
|
||||
|
|
@ -2211,7 +2247,9 @@ async def create_passkey_registration_options(
|
|||
)
|
||||
rp_id, _origin = _passkey_relying_party(request)
|
||||
store = _passkey_store()
|
||||
existing = await asyncio.to_thread(store.all)
|
||||
existing = await asyncio.to_thread(
|
||||
store.all, principal_id=request.state.dashboard_session.principal_id
|
||||
)
|
||||
options, challenge = passkeys.registration_options(
|
||||
rp_id=rp_id,
|
||||
excluded=[item.credential_id for item in existing],
|
||||
|
|
@ -2268,6 +2306,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_enrolled",
|
||||
principal_id=await _security_principal_id(request),
|
||||
method="passkey",
|
||||
device_label=current.device_label,
|
||||
target="passkey",
|
||||
|
|
@ -2284,6 +2323,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
|||
sign_count=verified.sign_count,
|
||||
device_label=current.device_label,
|
||||
management_id=current.management_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
try:
|
||||
|
|
@ -2305,7 +2345,10 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
|||
@app.get("/api/v1/passkeys")
|
||||
async def list_enrolled_passkeys(request: Request):
|
||||
try:
|
||||
credentials = await asyncio.to_thread(_passkey_store().all)
|
||||
credentials = await asyncio.to_thread(
|
||||
_passkey_store().all,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
|
|
@ -2352,7 +2395,11 @@ async def revoke_enrolled_passkey(
|
|||
)
|
||||
store = _passkey_store()
|
||||
try:
|
||||
credential = await asyncio.to_thread(store.get_management_id, management_id)
|
||||
credential = await asyncio.to_thread(
|
||||
store.get_management_id,
|
||||
management_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
|
|
@ -2371,6 +2418,7 @@ async def revoke_enrolled_passkey(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
device_label=credential.device_label,
|
||||
target="passkey",
|
||||
)
|
||||
|
|
@ -2418,8 +2466,16 @@ async def revoke_enrolled_passkey(
|
|||
|
||||
@app.post("/api/v1/passkeys/authentication/options")
|
||||
async def create_passkey_authentication_options(request: Request):
|
||||
try:
|
||||
principal_id, _principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
store = _passkey_store()
|
||||
credentials = await asyncio.to_thread(store.all)
|
||||
credentials = await asyncio.to_thread(store.all, principal_id=principal_id)
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
|
|
@ -2472,7 +2528,9 @@ async def create_passkey_authorization_options(
|
|||
payload: PasskeyAuthorizationTarget, request: Request
|
||||
):
|
||||
store = _passkey_store()
|
||||
credentials = await asyncio.to_thread(store.all)
|
||||
credentials = await asyncio.to_thread(
|
||||
store.all, principal_id=request.state.dashboard_session.principal_id
|
||||
)
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
||||
rp_id, _origin = _passkey_relying_party(request)
|
||||
|
|
@ -2509,7 +2567,11 @@ async def verify_passkey_authorization(
|
|||
action=payload.action,
|
||||
target=payload.target,
|
||||
)
|
||||
stored = await asyncio.to_thread(store.get, credential_id)
|
||||
stored = await asyncio.to_thread(
|
||||
store.get,
|
||||
credential_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
if not valid or stored is None:
|
||||
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
|
||||
rp_id, origin = _passkey_relying_party(request)
|
||||
|
|
@ -2532,6 +2594,7 @@ async def verify_passkey_authorization(
|
|||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
principal_id=await _security_principal_id(request),
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target=f"{payload.action}:{payload.target}",
|
||||
|
|
@ -2594,6 +2657,14 @@ async def verify_passkey_authentication(
|
|||
)
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
|
||||
store = _passkey_store()
|
||||
try:
|
||||
principal_id, principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
valid = await asyncio.to_thread(
|
||||
store.consume_challenge,
|
||||
challenge,
|
||||
|
|
@ -2602,7 +2673,9 @@ async def verify_passkey_authentication(
|
|||
action="sign_in",
|
||||
target="dashboard",
|
||||
)
|
||||
stored = await asyncio.to_thread(store.get, credential_id)
|
||||
stored = await asyncio.to_thread(
|
||||
store.get, credential_id, principal_id=principal_id
|
||||
)
|
||||
if not valid or stored is None:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
|
|
@ -2633,19 +2706,12 @@ async def verify_passkey_authentication(
|
|||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
principal_id=stored.principal_id,
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="sign_in:dashboard",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
try:
|
||||
principal_id, principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
await dashboard_auth.revoke_managed_session(stored.management_id)
|
||||
signed, session = await asyncio.to_thread(
|
||||
dashboard_auth.issue_session,
|
||||
|
|
@ -2679,6 +2745,7 @@ async def verify_passkey_authentication(
|
|||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
principal_id=stored.principal_id,
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="dashboard",
|
||||
|
|
@ -3055,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:
|
||||
return FollowingStore(
|
||||
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
||||
|
|
@ -3322,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")
|
||||
async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
|
|
@ -3780,6 +3965,7 @@ async def save_today_recap_and_log_time(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"gitea_time_logged",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -3880,6 +4066,7 @@ async def sign_out(request: Request, response: Response):
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"sign_out",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target="current_device",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -3990,6 +4177,7 @@ async def revoke_active_device(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"device_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
device_label=target.device_label,
|
||||
target="device",
|
||||
)
|
||||
|
|
@ -4001,7 +4189,9 @@ async def revoke_active_device(
|
|||
)
|
||||
try:
|
||||
revoked = await asyncio.to_thread(
|
||||
_passkey_store().revoke_device_access, management_id
|
||||
_passkey_store().revoke_device_access,
|
||||
management_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
|
|
@ -4040,6 +4230,7 @@ async def sign_out_all_devices(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"all_sessions_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target="all_devices",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -4049,14 +4240,17 @@ async def sign_out_all_devices(
|
|||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(_passkey_store().revoke_all_access)
|
||||
management_ids = await asyncio.to_thread(
|
||||
_passkey_store().revoke_all_access,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
await asyncio.to_thread(_push_subscription_store.delete_all)
|
||||
await asyncio.to_thread(_push_subscription_store.delete_sessions, management_ids)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -6610,6 +6804,7 @@ async def _delete_conversation_comment(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"comment_deleted",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -7065,6 +7260,7 @@ async def close_assigned_issue(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"issue_closed",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
@ -7668,13 +7864,38 @@ async def pull_action_failure(
|
|||
)
|
||||
async def retry_pull_action_job(
|
||||
retry: PullReadyRequest,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
) -> JSONResponse:
|
||||
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():
|
||||
if not _has_pull_workspace_access(
|
||||
|
|
@ -7690,14 +7911,26 @@ async def retry_pull_action_job(
|
|||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
except gitea_proxy.StalePullError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "New commits arrived. Reload checks before retrying this job."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "This check is no longer failed or cannot be retried."},
|
||||
status_code=409,
|
||||
|
|
@ -7709,6 +7942,10 @@ async def retry_pull_action_job(
|
|||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
|
@ -7970,6 +8207,7 @@ async def merge_assigned_pull(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"pull_merged",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -8061,6 +8299,7 @@ async def delete_merged_source_branch(
|
|||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"source_branch_deleted",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
@ -8162,14 +8401,39 @@ async def release_action_failure(
|
|||
"/checks/{run_id}/jobs/{job_index}/retry"
|
||||
)
|
||||
async def retry_release_action_job(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
|
||||
run_id: int = PathParam(gt=0),
|
||||
job_index: int = PathParam(ge=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
) -> JSONResponse:
|
||||
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():
|
||||
if not await gitea_proxy.can_recover_merged_release(
|
||||
|
|
@ -8185,8 +8449,16 @@ async def retry_release_action_job(
|
|||
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
except ValueError:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "This release check is no longer failed or cannot be retried."},
|
||||
status_code=409,
|
||||
|
|
@ -8198,6 +8470,10 @@ async def retry_release_action_job(
|
|||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
result, status_code=202, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
|
@ -8248,7 +8524,10 @@ async def prepare_release_rollback(
|
|||
journal = _security_event_store()
|
||||
try:
|
||||
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:
|
||||
return JSONResponse(
|
||||
|
|
@ -8346,6 +8625,7 @@ async def submit_review(
|
|||
if submission.decision == "approve"
|
||||
else "pull_review_changes_requested"
|
||||
),
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class StoredPasskey:
|
|||
device_label: str
|
||||
management_id: str
|
||||
created_at: int
|
||||
principal_id: int
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
|
|
@ -50,10 +51,19 @@ class PasskeyStore:
|
|||
sign_count INTEGER NOT NULL,
|
||||
device_label TEXT NOT NULL,
|
||||
management_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
principal_id INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
credential_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(passkey_credentials)")
|
||||
}
|
||||
if "principal_id" not in credential_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE passkey_credentials ADD COLUMN principal_id INTEGER"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
|
|
@ -172,12 +182,14 @@ class PasskeyStore:
|
|||
sign_count: int,
|
||||
device_label: str,
|
||||
management_id: str,
|
||||
principal_id: int,
|
||||
) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
|
||||
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"device_label, management_id, created_at, principal_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
credential_id,
|
||||
public_key,
|
||||
|
|
@ -185,41 +197,48 @@ class PasskeyStore:
|
|||
device_label,
|
||||
management_id,
|
||||
int(self.clock()),
|
||||
principal_id,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def all(self) -> list[StoredPasskey]:
|
||||
def all(self, *, principal_id: int) -> list[StoredPasskey]:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials ORDER BY created_at DESC"
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE principal_id = ? ORDER BY created_at DESC",
|
||||
(principal_id,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return [StoredPasskey(*row) for row in rows]
|
||||
|
||||
def get(self, credential_id: bytes) -> StoredPasskey | None:
|
||||
def get(self, credential_id: bytes, *, principal_id: int) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE credential_id = ?",
|
||||
(credential_id,),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE credential_id = ? AND principal_id = ?",
|
||||
(credential_id, principal_id),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def get_management_id(self, management_id: str) -> StoredPasskey | None:
|
||||
def get_management_id(
|
||||
self, management_id: str, *, principal_id: int
|
||||
) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
|
@ -303,13 +322,14 @@ class PasskeyStore:
|
|||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_device_access(self, management_id: str) -> bool:
|
||||
def revoke_device_access(self, management_id: str, *, principal_id: int) -> bool:
|
||||
"""Atomically remove one active session, its grants, and its linked passkey."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
"SELECT session_hash FROM active_sessions "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
return False
|
||||
|
|
@ -317,22 +337,44 @@ class PasskeyStore:
|
|||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
"DELETE FROM passkey_credentials "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
"DELETE FROM active_sessions "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self) -> None:
|
||||
"""Atomically remove every passkey, challenge, grant, and active session."""
|
||||
def revoke_all_access(self, *, principal_id: int) -> list[str]:
|
||||
"""Atomically remove one principal's passkeys, grants, and active sessions."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
management_ids = [
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT management_id FROM active_sessions "
|
||||
"WHERE principal_id = ? ORDER BY management_id",
|
||||
(principal_id,),
|
||||
).fetchall()
|
||||
]
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash IN ("
|
||||
"SELECT session_hash FROM active_sessions WHERE principal_id = ?)",
|
||||
(principal_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
return management_ids
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
|
|
|||
|
|
@ -133,6 +133,9 @@ class DisabledPushSubscriptionStore:
|
|||
def delete_session(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_sessions(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_all(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
|
@ -442,6 +445,17 @@ class PushSubscriptionStore:
|
|||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
|
||||
def delete_sessions(self, session_ids) -> None:
|
||||
requested = set(session_ids)
|
||||
if not requested:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
f"DELETE FROM push_subscriptions WHERE session_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
)
|
||||
|
||||
def delete_all(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions")
|
||||
|
|
|
|||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
|
|
@ -93,6 +94,10 @@ class SecurityEventStore:
|
|||
connection.execute(
|
||||
"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"}
|
||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||
self._migrate_plaintext(
|
||||
|
|
@ -102,6 +107,10 @@ class SecurityEventStore:
|
|||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"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(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
|
|
@ -126,6 +135,7 @@ class SecurityEventStore:
|
|||
CREATE TABLE security_events_encrypted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
|
|
@ -145,7 +155,8 @@ class SecurityEventStore:
|
|||
) in rows:
|
||||
connection.execute(
|
||||
"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,
|
||||
payload
|
||||
|
|
@ -195,6 +206,7 @@ class SecurityEventStore:
|
|||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
|
|
@ -203,8 +215,9 @@ class SecurityEventStore:
|
|||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
||||
(now,),
|
||||
"INSERT INTO security_events(principal_id, created_at, status) "
|
||||
"VALUES (?, ?, 'completed')",
|
||||
(principal_id, now),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
payload = self._seal_event(
|
||||
|
|
@ -224,6 +237,7 @@ class SecurityEventStore:
|
|||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
|
|
@ -233,9 +247,9 @@ class SecurityEventStore:
|
|||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
||||
"VALUES (?, 'pending', ?)",
|
||||
(now, operation_id),
|
||||
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, 'pending', ?)",
|
||||
(principal_id, now, operation_id),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
connection.execute(
|
||||
|
|
@ -282,12 +296,14 @@ class SecurityEventStore:
|
|||
"Security activity is temporarily unavailable"
|
||||
) 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))
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
parameters: list[int] = [principal_id]
|
||||
where = "WHERE principal_id = ?"
|
||||
if cursor is not None:
|
||||
where = "WHERE id < ?"
|
||||
where += " AND id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -259,16 +259,18 @@ class SessionStore:
|
|||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
|
||||
def list_active(
|
||||
self, current_session_id: str, *, principal_id: int
|
||||
) -> list[ActiveDevice]:
|
||||
now = int(self.clock())
|
||||
current_hash = self._digest(current_session_id)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, device_label, created_at, expires_at, session_hash "
|
||||
"FROM active_sessions WHERE expires_at > ? "
|
||||
"FROM active_sessions WHERE expires_at > ? AND principal_id = ? "
|
||||
"ORDER BY expires_at DESC, created_at DESC",
|
||||
(now,),
|
||||
(now, principal_id),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||
|
|
@ -10,4 +12,15 @@ os.environ.setdefault(
|
|||
os.environ.setdefault(
|
||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||
"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)
|
||||
|
|
@ -59,3 +59,173 @@ def test_adaptive_queues_put_truthful_next_action_above_the_fold(viewport):
|
|||
expect(page.locator(".mobile-queue-all")).not_to_have_attribute("open", "")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
@pytest.mark.parametrize(("queue", "count", "label", "destination"), [
|
||||
("following", 3, "Review Following (3)", ["following"]),
|
||||
("authored", 2, "Open My PRs (2)", ["filter:authored", "open:authored"]),
|
||||
])
|
||||
def test_adaptive_queues_open_existing_following_and_authored_work(viewport, queue, count, label, destination):
|
||||
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-queue-launcher.js")
|
||||
page.evaluate("""({queue, count}) => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.destinations = [];
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({[queue]:count}),
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
openFollowing:() => window.destinations.push('following'),
|
||||
selectFilter:name => window.destinations.push('filter:' + name),
|
||||
firstAction:name => name === 'authored' ? {click:() => window.destinations.push('open:authored')} : null,
|
||||
announce:message => window.destinations.push('announce:' + message),
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-next-action').addEventListener(
|
||||
'click', () => window.adaptiveQueueLauncher.continueWork()
|
||||
);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""", {"queue": queue, "count": count})
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text(label)
|
||||
expect(next_action).to_have_attribute("aria-label", "Next up: " + label)
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == [queue]
|
||||
|
||||
next_action.focus()
|
||||
next_action.press("Enter")
|
||||
assert page.evaluate("window.destinations") == destination
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
|
||||
viewport = {"width": 390, "height": 844}
|
||||
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-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.online = false;
|
||||
window.destinations = [];
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({delivery:1, gate:2, today:3}),
|
||||
isOnline:() => window.online,
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
openToday:() => window.destinations.push('today'),
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-next-action').addEventListener(
|
||||
'click', () => window.adaptiveQueueLauncher.continueWork()
|
||||
);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text("Continue Today (3)")
|
||||
next_action.press("Enter")
|
||||
assert page.evaluate("window.destinations") == ["today"]
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.online = true;
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
}""")
|
||||
expect(next_action).to_have_text("Recover Delivery (1)")
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
def test_operator_reorders_routine_queues_without_leaking_priority_between_accounts(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-queue-priority.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
window.login = 'alice';
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.launcher = createMobileQueueLauncher({
|
||||
getCounts:() => ({attention:2, following:3, authored:1}),
|
||||
getRoutineOrder:() => window.priority.getOrder(), rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
});
|
||||
window.priority = createMobileQueuePriority({
|
||||
storage:{getItem:key => values.get(key) || null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:() => window.login, document,
|
||||
list:document.querySelector('#mobile-queue-priority-list'),
|
||||
resetButton:document.querySelector('#reset-mobile-queue-priority'),
|
||||
status:document.querySelector('#mobile-queue-priority-status'),
|
||||
labels:{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'},
|
||||
onChange:() => window.launcher.renderPresentation(),
|
||||
});
|
||||
window.priority.start(); window.launcher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-priority').open = true;
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
for _ in range(4):
|
||||
page.get_by_role("button", name="Move Following earlier").click()
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Review Following (3)")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["following", "attention", "authored"]
|
||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
|
||||
controls = page.locator(".mobile-queue-priority-controls button")
|
||||
assert controls.count() == 18
|
||||
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.login = 'bob'; window.priority.render(); window.launcher.renderPresentation();
|
||||
}""")
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Start Attention (2)")
|
||||
assert page.locator("#mobile-queue-priority-list [data-queue-priority]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.queuePriority)"
|
||||
)[:3] == ["attention", "today", "update"]
|
||||
browser.close()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
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)])
|
||||
|
|
@ -29,7 +29,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
|||
current = {"gate": "g1"}
|
||||
evidence_url = {"value": "/evidence/manifest"}
|
||||
list_requests: list[str] = []
|
||||
decision_requests: list[str] = []
|
||||
decision_requests: list[dict] = []
|
||||
browser_errors: list[str] = []
|
||||
|
||||
def gate(gate_id: str) -> dict:
|
||||
|
|
@ -62,7 +62,14 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
|||
def human_gates_route(route):
|
||||
path = route.request.url.split("?", 1)[0]
|
||||
if route.request.method == "POST":
|
||||
decision_requests.append(path)
|
||||
decision_requests.append({
|
||||
"path": path,
|
||||
"key": route.request.headers.get("idempotency-key"),
|
||||
"body": route.request.post_data_json,
|
||||
})
|
||||
if len(decision_requests) == 1:
|
||||
route.abort("failed")
|
||||
return
|
||||
payload = {"receipt_id": "receipt-1", "state": "released"}
|
||||
elif path.endswith("/api/v1/human-gates"):
|
||||
list_requests.append(current["gate"])
|
||||
|
|
@ -78,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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
|
|
@ -119,6 +127,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
|||
popup.close()
|
||||
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
|
||||
|
|
@ -131,9 +141,30 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
|||
assert progress_key and progress_key.endswith(":g1:1")
|
||||
assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
|
||||
page.locator('[data-gate-decision="release"]').click()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||
expect(page.locator("[data-gate-recover]")).to_have_count(1)
|
||||
decision_key = page.evaluate(
|
||||
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-decision.v1:'))"
|
||||
)
|
||||
assert decision_key
|
||||
assert len(decision_requests) == 1
|
||||
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||
recover = page.locator("[data-gate-recover]")
|
||||
expect(recover).to_be_visible()
|
||||
recover_height = recover.evaluate("element => element.getBoundingClientRect().height")
|
||||
assert recover_height >= 44
|
||||
recover.click()
|
||||
expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
|
||||
expect(page.locator(".human-gate-decision-tray")).to_have_count(0)
|
||||
assert len(decision_requests) == 1
|
||||
assert len(decision_requests) == 2
|
||||
assert decision_requests[0] == decision_requests[1]
|
||||
assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key)
|
||||
assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
|
||||
|
||||
page.evaluate("document.querySelector('#close-human-gates').click()")
|
||||
|
|
@ -218,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("#submit-sign-in").click()
|
||||
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()")
|
||||
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()
|
||||
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
|
||||
|
|
@ -238,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-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
|
||||
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")
|
||||
for selector in ("#human-gates-pending", "#human-gates-history"):
|
||||
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("#submit-sign-in").click()
|
||||
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")
|
||||
|
||||
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(page.locator("#find-work-sheet")).to_be_visible()
|
||||
page.locator("#close-find-work").click()
|
||||
expect(page.locator("#find-work-sheet")).to_be_hidden()
|
||||
|
||||
context.set_offline(True)
|
||||
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()
|
||||
|
||||
page.locator("#close-mobile-first-task").click()
|
||||
expect(sheet).to_be_hidden()
|
||||
context.set_offline(False)
|
||||
page.evaluate(
|
||||
probe_result = page.evaluate(
|
||||
"""() => {
|
||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||
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,
|
||||
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]")
|
||||
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")
|
||||
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(coach).to_be_hidden()
|
||||
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")
|
||||
|
||||
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("#submit-sign-in").click()
|
||||
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")
|
||||
fresh_page.wait_for_load_state("networkidle")
|
||||
assert fresh_page.evaluate(
|
||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||
) == "complete"
|
||||
|
|
@ -128,6 +176,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
optional_workspace_requests: list[str] = []
|
||||
launch_transfer_events: 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,
|
||||
)
|
||||
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(
|
||||
"requestfinished",
|
||||
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")
|
||||
assert optional_workspace_requests == []
|
||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
live_requests.clear()
|
||||
|
|
@ -194,9 +250,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
), launch_transfer_events
|
||||
|
||||
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]
|
||||
assert len(revisionless_live_requests) == 1, 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(
|
||||
"""
|
||||
() => {
|
||||
|
|
@ -226,18 +297,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
bounds = control.bounding_box()
|
||||
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(".mobile-queue-all summary").click()
|
||||
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:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
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")
|
||||
|
||||
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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="find"]').click()
|
||||
|
||||
page.wait_for_timeout(1500)
|
||||
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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate(
|
||||
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||
)
|
||||
|
||||
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("#submit-sign-in").click()
|
||||
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-queue="agenda"]').click()
|
||||
|
|
|
|||
|
|
@ -114,6 +114,13 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
|||
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]:
|
||||
return page.evaluate(
|
||||
"""async () => {
|
||||
|
|
@ -275,6 +282,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
assert fake.created_issues == []
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||
durable_after_reload = indexed_issue_records(page)
|
||||
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
||||
|
|
@ -306,9 +314,13 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
f"requests={fake.requests[-20:]!r}"
|
||||
)
|
||||
# Deferred workspace startup can report its already-issued offline request
|
||||
# after the pre-reconnect clear; retain every non-offline browser error.
|
||||
# after the pre-reconnect clear. Chromium uses either its explicit offline
|
||||
# code or a generic fetch rejection for that same request, depending on
|
||||
# whether the service worker or page observes the disconnect first.
|
||||
browser_errors[:] = [
|
||||
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
error for error in browser_errors
|
||||
if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
and not error.startswith("context failed TypeError: Failed to fetch")
|
||||
]
|
||||
|
||||
for _ in range(40):
|
||||
|
|
@ -318,6 +330,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
page.wait_for_timeout(100)
|
||||
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(40):
|
||||
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 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):
|
||||
|
|
@ -60,6 +60,7 @@ def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work
|
|||
})"""
|
||||
)
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
stored = page.evaluate(
|
||||
"""() => new Promise((resolve,reject)=>{
|
||||
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 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):
|
||||
|
|
@ -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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
try:
|
||||
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');
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
expect(page.locator("#today-sync-status")).to_have_text(
|
||||
"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(
|
||||
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":
|
||||
page.locator("#close-issue-sheet").click()
|
||||
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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
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';
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||
expect(restored_pause).to_be_visible()
|
||||
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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
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")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||
page.locator("#close-issue-sheet").click()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
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)])
|
||||
|
|
@ -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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
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)])
|
||||
|
|
@ -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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
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)])
|
||||
|
|
@ -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("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
def stage_phone_plan():
|
||||
page.evaluate(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
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):
|
||||
|
|
@ -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:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,39 @@ from src.passkey_store import PasskeyStore
|
|||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
|
||||
def test_global_access_revocation_preserves_other_principal_credentials_and_sessions(
|
||||
tmp_path,
|
||||
):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"credential-{principal_id}".encode(),
|
||||
public_key=f"public-key-{principal_id}".encode(),
|
||||
sign_count=0,
|
||||
device_label=f"Device {principal_id}",
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
)
|
||||
|
||||
passkeys.revoke_all_access(principal_id=42)
|
||||
|
||||
assert passkeys.all(principal_id=42) == []
|
||||
assert [item.principal_id for item in passkeys.all(principal_id=84)] == [84]
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
|
||||
|
||||
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
|
|
@ -14,6 +47,8 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
|
|||
2_000,
|
||||
management_id="phone-management-id",
|
||||
device_label="Phone",
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
"phone-session",
|
||||
|
|
@ -28,6 +63,7 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
passkeys.issue_challenge(
|
||||
b"pending-challenge",
|
||||
|
|
@ -43,7 +79,7 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
|
|||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_all_access()
|
||||
passkeys.revoke_all_access(principal_id=42)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
|
||||
|
|
@ -63,6 +99,8 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
2_000,
|
||||
management_id=f"{slug}-management-id",
|
||||
device_label=label,
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"{slug}-session",
|
||||
|
|
@ -76,6 +114,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
sign_count=0,
|
||||
device_label=label,
|
||||
management_id=f"{slug}-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
|
|
@ -85,7 +124,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_device_access("phone-management-id")
|
||||
passkeys.revoke_device_access("phone-management-id", principal_id=42)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
|
|
@ -95,3 +134,69 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
|
||||
|
||||
|
||||
def test_device_access_revocation_cannot_cross_principal_boundary(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"session-{principal_id}",
|
||||
action="close_issue",
|
||||
target="stackchain/dashboard#1",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"credential-{principal_id}".encode(),
|
||||
public_key=f"public-key-{principal_id}".encode(),
|
||||
sign_count=0,
|
||||
device_label=f"Device {principal_id}",
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
)
|
||||
|
||||
assert passkeys.revoke_device_access("management-84", principal_id=42) is False
|
||||
assert passkeys.revoke_device_access("management-42", principal_id=42) is True
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM passkey_credentials ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_global_access_revocation_returns_only_affected_management_ids(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
for suffix in ("phone", "laptop"):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}-{suffix}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}-{suffix}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
|
||||
affected = passkeys.revoke_all_access(principal_id=42)
|
||||
|
||||
assert affected == ["management-42-laptop", "management-42-phone"]
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM active_sessions ORDER BY management_id"
|
||||
).fetchall() == [
|
||||
("management-84-laptop",),
|
||||
("management-84-phone",),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from src import main
|
|||
|
||||
@pytest.mark.anyio
|
||||
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():
|
||||
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):
|
||||
return []
|
||||
|
||||
async def empty_live_snapshot(**_revisions):
|
||||
return main.JSONResponse({})
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
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:
|
||||
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
||||
response = await client.get(path)
|
||||
|
|
|
|||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
|||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
script = f"""
|
||||
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 "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" 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():
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ def test_release_rollback_is_a_supported_step_up_action():
|
|||
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
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
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"]
|
||||
|
||||
|
||||
@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
|
||||
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
|
|
@ -135,7 +291,8 @@ async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization
|
|||
def record(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
assert principal_id == 42
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "cleanup-operation"
|
||||
|
||||
|
|
@ -330,6 +487,56 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
|||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_enrolled_for_another_upstream_principal_is_not_advertised(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
identity = {"id": 42, "login": "timmy"}
|
||||
|
||||
async def upstream_user():
|
||||
return dict(identity)
|
||||
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
monkeypatch.setattr(main, "current_user", upstream_user)
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_registration",
|
||||
lambda **_kwargs: VerifiedRegistration(),
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as owner:
|
||||
await owner.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": owner.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await fresh_grant(owner, "enroll_passkey", "current_device")
|
||||
options = await owner.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
enrolled = await owner.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
identity.update(id=84, login="other-operator")
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as other:
|
||||
denied = await other.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert enrolled.status_code == 201
|
||||
assert denied.status_code == 404
|
||||
assert denied.json() == {"detail": "No passkeys enrolled"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||
access_control, monkeypatch
|
||||
|
|
@ -349,6 +556,7 @@ async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
|||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
|
|
@ -364,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 "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||
|
|
@ -391,6 +599,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
|||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
|
|
@ -418,7 +627,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
|||
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 denied.status_code == 401
|
||||
assert "grant" not in denied.json()
|
||||
|
|
@ -438,6 +647,7 @@ async def test_passkey_options_are_source_limited_before_challenge_generation(
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
first_source = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
other_source = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
|
|
@ -470,6 +680,7 @@ async def test_failed_passkey_verification_consumes_the_shared_sign_in_budget(
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
|
||||
|
||||
|
|
@ -520,6 +731,7 @@ async def test_successful_passkey_sign_in_clears_prior_source_failures(
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
source = "203.0.113.10"
|
||||
attempts = main._login_attempt_store()
|
||||
|
|
@ -713,6 +925,7 @@ async def test_device_revocation_failure_preserves_its_passkey_and_session(
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=phone_device["management_id"],
|
||||
principal_id=42,
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_device", phone_device["management_id"]
|
||||
|
|
@ -783,6 +996,7 @@ async def test_orphaned_passkey_is_listed_safely_and_can_be_selectively_revoked(
|
|||
sign_count=0,
|
||||
device_label="Backup key",
|
||||
management_id="backup-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as laptop:
|
||||
|
|
@ -863,6 +1077,7 @@ async def test_removing_a_remote_passkey_also_revokes_its_active_session(access_
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=phone_device["management_id"],
|
||||
principal_id=42,
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_passkey", phone_device["management_id"]
|
||||
|
|
@ -907,6 +1122,7 @@ async def test_removing_the_current_passkey_keeps_the_current_session_active(acc
|
|||
sign_count=0,
|
||||
device_label="Laptop",
|
||||
management_id=current["management_id"],
|
||||
principal_id=42,
|
||||
)
|
||||
grant = await fresh_grant(client, "revoke_passkey", current["management_id"])
|
||||
removed = await client.delete(
|
||||
|
|
@ -1878,7 +2094,7 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
|||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
|
||||
class BrokenStore:
|
||||
def revoke_all_access(self):
|
||||
def revoke_all_access(self, *, principal_id):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main, "_passkey_store", lambda: BrokenStore())
|
||||
|
|
@ -1915,6 +2131,7 @@ async def test_sign_out_all_devices_failure_preserves_passkeys_and_sessions(acce
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=device["management_id"],
|
||||
principal_id=42,
|
||||
)
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
|
|
|
|||
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)
|
||||
|
|
@ -275,7 +275,7 @@ const feature=createFollowing({{
|
|||
]
|
||||
|
||||
|
||||
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||
def test_following_opens_explicitly_and_becomes_work_recommendation():
|
||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(launcher))});
|
||||
|
|
@ -293,7 +293,7 @@ process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommend
|
|||
).stdout)
|
||||
assert result == {
|
||||
"opened": "opened-following",
|
||||
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"recommended": {"name": "following", "count": 7, "label": "Review Following (7)"},
|
||||
"calls": ["following"],
|
||||
}
|
||||
|
||||
|
|
@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
|||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v149" in service_worker
|
||||
assert "stackchain-dashboard-shell-v150" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
|
|
|
|||
|
|
@ -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.endswith(".js")
|
||||
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"
|
||||
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]
|
||||
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():
|
||||
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"
|
||||
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"},
|
||||
)
|
||||
|
||||
events = journal.list().events
|
||||
events = journal.list(principal_id=1).events
|
||||
assert decided.status_code == 201
|
||||
assert [
|
||||
{"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 journal.list().events == []
|
||||
assert journal.list(principal_id=1).events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_
|
|||
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
||||
|
||||
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
|
||||
|
|
@ -548,6 +548,76 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
|
|||
assert output["keys"][0] == output["keys"][1]
|
||||
|
||||
|
||||
def test_interrupted_decision_is_recovered_after_reload_with_the_exact_operation():
|
||||
output = run_node(r"""
|
||||
const stored=new Map(); const requests=[]; let failResponse=true, committed=false;
|
||||
const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)};
|
||||
const item={id:'g1',title:'Release candidate',candidate_hash:'a1',revision:3,checks:[]};
|
||||
const createController=()=>{
|
||||
const detail={innerHTML:'',addEventListener(){},querySelectorAll:()=>[],querySelector:()=>null};
|
||||
const gates=createHumanGates({
|
||||
storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,
|
||||
nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
|
||||
fetchJson:async(path,options={})=>{
|
||||
if(options.method==='POST') {
|
||||
requests.push({key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)});
|
||||
if(failResponse) throw new Error('response interrupted');
|
||||
return {receipt_id:'r1',state:'held'};
|
||||
}
|
||||
return committed ? {pending_count:0,items:[]} : {pending_count:1,items:[item]};
|
||||
},
|
||||
});
|
||||
return {gates,detail};
|
||||
};
|
||||
(async()=>{
|
||||
const first=createController(); await first.gates.load(); first.gates.reviewNext();
|
||||
first.gates.saveProgress({reason:'Awaiting approval',checklist:{}});
|
||||
try { await first.gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {}
|
||||
const immediateHtml=first.detail.innerHTML;
|
||||
const decisionStorageKey=[...stored.keys()].find(key=>key.startsWith('stackchain.human-gate-decision.v1:'));
|
||||
const pendingBefore=JSON.parse(stored.get(decisionStorageKey));
|
||||
committed=true; failResponse=false;
|
||||
const second=createController(); await second.gates.load(); second.gates.reviewNext();
|
||||
const recoveryHtml=second.detail.innerHTML;
|
||||
const result=await second.gates.recoverDecision();
|
||||
process.stdout.write(JSON.stringify({
|
||||
decisionStorageKey,pendingBefore,immediateHtml,recoveryHtml,requests,result,
|
||||
pendingCleared:!stored.has(decisionStorageKey),
|
||||
progressCleared:![...stored.keys()].some(key=>key.startsWith('stackchain.human-gate-review.v1:')),
|
||||
snapshot:second.gates.snapshot(),
|
||||
}));
|
||||
})().catch(error=>process.stdout.write(JSON.stringify({fatal:error.stack})));
|
||||
""")
|
||||
assert "fatal" not in output, output.get("fatal")
|
||||
assert output["decisionStorageKey"] == "stackchain.human-gate-decision.v1:7:timmy"
|
||||
assert output["pendingBefore"]["gate_id"] == "g1"
|
||||
assert output["pendingBefore"]["revision"] == 3
|
||||
assert output["pendingBefore"]["decision"] == "hold"
|
||||
assert "Decision outcome unknown" in output["immediateHtml"]
|
||||
assert "Decision outcome unknown" in output["recoveryHtml"]
|
||||
assert "data-gate-recover" in output["recoveryHtml"]
|
||||
assert len(output["requests"]) == 2
|
||||
assert output["requests"][0] == output["requests"][1]
|
||||
assert output["pendingCleared"] is True
|
||||
assert output["progressCleared"] is True
|
||||
assert output["snapshot"] == {"pending_count": 0, "items": []}
|
||||
assert output["result"]["receipt"]["receipt_id"] == "r1"
|
||||
|
||||
|
||||
def test_interrupted_decision_recovery_is_wired_for_progressive_and_hydrated_mobile_shells():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
progressive = PROGRESSIVE.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert "closest('[data-gate-recover]')" in dashboard
|
||||
assert "humanGates.recoverDecision()" in dashboard
|
||||
assert "closest?.('[data-gate-recover]')" in progressive
|
||||
assert "controller.recoverDecision()" in progressive
|
||||
assert ".human-gate-decision-recovery" in css
|
||||
assert ".human-gate-decision-recovery button{min-height:44px" in css
|
||||
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it():
|
||||
output = run_node(r"""
|
||||
const stored=new Map(); let attempts=0;
|
||||
|
|
@ -707,7 +777,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
|||
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
|
||||
assert "counts.gate = queueCounts.gate" in dashboard
|
||||
assert "gate:preparationItems.gate || []" in dashboard
|
||||
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
|
||||
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell():
|
||||
|
|
@ -728,7 +798,7 @@ def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(
|
|||
assert ".human-gate-views button{min-height:44px" in css
|
||||
assert ".human-gate-history-card time" in css
|
||||
assert ".human-gate-history-more{min-height:44px;width:100%" in css
|
||||
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
|
||||
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
|
||||
|
|
|
|||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
|||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
|||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
|
|
|
|||
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
|
||||
|
|
@ -295,6 +295,25 @@ process.stdout.write(JSON.stringify({{first, ready, opened}}));
|
|||
}
|
||||
|
||||
|
||||
def test_start_day_view_can_render_status_without_own_action_button():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const elements = {{summary:{{textContent:''}}, phases:{{textContent:''}}}};
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => ({{attention:2, today:3}}),
|
||||
openQueue: () => {{}},
|
||||
elements,
|
||||
}});
|
||||
controller.start();
|
||||
process.stdout.write(JSON.stringify({{summary:elements.summary.textContent, phases:elements.phases.textContent}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"summary": "2 items before Today · 3 planned",
|
||||
"phases": "Attention 2",
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_pass_persists_per_account_and_hands_off_using_live_counts():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
|
|
@ -440,6 +459,22 @@ process.stdout.write(JSON.stringify(JSON.parse(saved.get('stackchain.mobile-star
|
|||
assert run_node(script) == {"login": "timmy", "day": "2026-08-15", "phase": "agenda"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queues_render_one_adaptive_start_or_continue_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-next-heading">Start / Continue</p>' in html
|
||||
assert html.count('id="mobile-queue-next-action"') == 1
|
||||
assert 'id="mobile-start-day-action"' not in html
|
||||
assert "getPreparation: () =>" in html
|
||||
assert "openPreparation: () => mobileStartDay.startNext()" in html
|
||||
assert "action: qs('#mobile-start-day-action')" not in html
|
||||
assert "qs('#mobile-queue-next-action').focus();" in html
|
||||
assert "mobileStartDay.finish();" in html
|
||||
assert "renderMobileQueuePresentation();" in html
|
||||
assert "qs('#finish-mobile-start-day').addEventListener('click', () => {" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile_bundle():
|
||||
html = await dashboard()
|
||||
|
|
@ -448,7 +483,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
|||
assert 'class="mobile-start-day"' in html
|
||||
assert 'id="mobile-start-day-summary"' in html
|
||||
assert 'id="mobile-start-day-phases"' in html
|
||||
assert 'id="mobile-start-day-action"' in html
|
||||
assert 'id="mobile-queue-next-action"' in html
|
||||
assert 'id="finish-mobile-start-day"' in html
|
||||
assert '<script src="static/mobile-start-day.js"></script>' in html
|
||||
assert "const mobileStartDay = createMobileStartDay({" in html
|
||||
|
|
@ -465,11 +500,11 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
|||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
|
||||
assert "mobileStartDay.render();" in html
|
||||
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
|
||||
assert ".mobile-queue-next button { min-height:48px; width:100%;" in html
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v149" in service_worker
|
||||
assert "stackchain-dashboard-shell-v150" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -481,7 +516,7 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie
|
|||
assert "function openMobileStartDay()" in html
|
||||
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
||||
assert "qs('#mobile-queue-sheet').showModal();" in html
|
||||
assert "qs('#mobile-start-day-action').focus();" in html
|
||||
assert "qs('#mobile-queue-next-action').focus();" in html
|
||||
assert "let preparationItems = {};" in html
|
||||
assert "getPhaseItems: () => preparationItems" in html
|
||||
assert "preparationItems = {" in html
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from tests.dashboard_bundle import dashboard
|
|||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
|
||||
QUEUE_PRIORITY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-priority.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
|
|
@ -48,6 +49,282 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_queue_priority_persists_complete_account_scoped_routine_order():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
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 priority = createPriority({{storage, getLogin:() => login}});
|
||||
const original = priority.getOrder();
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
const alice = priority.getOrder();
|
||||
login = 'bob';
|
||||
const bob = priority.getOrder();
|
||||
login = '';
|
||||
const anonymous = priority.getOrder();
|
||||
login = 'alice';
|
||||
priority.reset();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
original, alice, bob, anonymous, reset:priority.getOrder(), keys:Array.from(values.keys()),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
default = ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"]
|
||||
assert json.loads(result.stdout) == {
|
||||
"original": default,
|
||||
"alice": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"bob": default,
|
||||
"anonymous": default,
|
||||
"reset": default,
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
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():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
class Element {{
|
||||
constructor(tag='div') {{ this.tag=tag; this.children=[]; this.listeners={{}}; this.attributes={{}}; this.disabled=false; this.textContent=''; }}
|
||||
append(...items) {{ this.children.push(...items); }}
|
||||
replaceChildren(...items) {{ this.children=[...items]; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
click() {{ this.listeners.click?.(); }}
|
||||
}}
|
||||
const values = new Map();
|
||||
const list = new Element(); const resetButton = new Element('button'); const status = new Element();
|
||||
const priority = createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', document:{{createElement:tag=>new Element(tag)}}, list, resetButton, status,
|
||||
labels:{{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'}},
|
||||
}});
|
||||
priority.start();
|
||||
for (let index=0; index<3; index += 1) {{
|
||||
const row = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
|
||||
row.children[1].children[0].click();
|
||||
}}
|
||||
const following = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
order:list.children.map(item => item.attributes['data-queue-priority']),
|
||||
earlierLabel:following.children[1].children[0].attributes['aria-label'],
|
||||
laterLabel:following.children[1].children[1].attributes['aria-label'],
|
||||
status:status.textContent,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"earlierLabel": "Move Following earlier",
|
||||
"laterLabel": "Move Following later",
|
||||
"status": "Sync pending.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
|
|
@ -300,6 +577,76 @@ async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_d
|
|||
assert html.count(f'<button data-mobile-queue="{name}"') == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-priority"' in html
|
||||
assert 'id="mobile-queue-priority-list"' in html
|
||||
assert 'id="reset-mobile-queue-priority"' in html
|
||||
assert 'id="mobile-queue-priority-status" role="status" aria-live="polite"' in html
|
||||
assert '<script src="static/mobile-queue-priority.js"></script>' in html
|
||||
assert "getRoutineOrder: () => mobileQueuePriority?.getOrder()" in html
|
||||
assert "createMobileQueuePriority({" in html
|
||||
assert "getLogin: () => confirmedOwnerLogin" in html
|
||||
assert "mobileQueuePriority.render();\n renderMobileQueuePresentation();" 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
|
||||
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let offlineWorkMode = false;" in html
|
||||
assert "isOnline: () => !offlineWorkMode" in html
|
||||
assert (
|
||||
"offlineWorkMode = value;\n"
|
||||
" renderMobileQueuePresentation();"
|
||||
) in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_hydration_refreshes_the_persistent_mobile_queue_census():
|
||||
html = await dashboard()
|
||||
|
||||
assert (
|
||||
"preparationItems.following = items.filter(item => item.has_unseen_change === true);\n"
|
||||
" renderMobileQueuePresentation();\n"
|
||||
" mobileTaskDock.updateQueues(queueCounts);\n"
|
||||
" mobileStartDay.render();"
|
||||
) in html
|
||||
assert (
|
||||
"queueCounts.followingUnavailable = status === 'error';\n"
|
||||
" renderMobileQueuePresentation();\n"
|
||||
" mobileTaskDock.updateQueues(queueCounts);\n"
|
||||
" mobileStartDay.render();"
|
||||
) in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_work_refresh_preserves_the_hydrated_following_queue():
|
||||
html = await dashboard()
|
||||
|
||||
assert "counts.following = queueCounts.following;" in html
|
||||
assert "counts.followingUnavailable = queueCounts.followingUnavailable;" in html
|
||||
assert "following:preparationItems.following || []," in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
|
||||
html = await dashboard()
|
||||
|
|
@ -548,7 +895,7 @@ process.stdout.write(JSON.stringify({{
|
|||
"populated": {
|
||||
"badge": "8 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Human Gates 2, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 8 active queues",
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Human Gates 2, Attention 1, Updates 5, Following 0, Filed 2, My PRs 0, Later 3, Drafts 4; 8 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
|
|
@ -557,7 +904,7 @@ process.stdout.write(JSON.stringify({{
|
|||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 7, Following 0, Filed 0, My PRs 0, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
|
|
@ -570,6 +917,59 @@ process.stdout.write(JSON.stringify({{
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_counts_following_and_authored_work_truthfully():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const node = () => ({{
|
||||
textContent:'', hidden:false, attributes:{{}},
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
}});
|
||||
const queues = node();
|
||||
const badge = node();
|
||||
const deadline = node();
|
||||
const following = node();
|
||||
const authored = node();
|
||||
const dock = createDock({{
|
||||
nav:node(), buttons:{{queues}}, queueBadge:badge, deadlineBadge:deadline,
|
||||
queueRows:{{following, authored}},
|
||||
queueCounts:{{following:node(), authored:node()}},
|
||||
}});
|
||||
dock.updateQueues({{following:3, authored:2}});
|
||||
const active = {{
|
||||
badge:badge.textContent,
|
||||
badgeHidden:badge.hidden,
|
||||
summary:queues.attributes['aria-label'],
|
||||
followingCount:dock ? following.attributes['aria-label'] : null,
|
||||
}};
|
||||
dock.updateQueues({{following:0, authored:0}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
active,
|
||||
cleared:{{
|
||||
badgeHidden:badge.hidden,
|
||||
summary:queues.attributes['aria-label'],
|
||||
followingLabel:following.attributes['aria-label'],
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"active": {
|
||||
"badge": "2 active",
|
||||
"badgeHidden": False,
|
||||
"summary": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 0, Following 3, Filed 0, My PRs 2, Later 0, Drafts 0; 2 active queues",
|
||||
"followingCount": "Following, 3 unseen changes",
|
||||
},
|
||||
"cleared": {
|
||||
"badgeHidden": True,
|
||||
"summary": "Queues: no active queues; no upcoming deadlines",
|
||||
"followingLabel": "Following, 0 unseen changes",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
|
|
@ -690,6 +1090,36 @@ process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}))
|
|||
}
|
||||
|
||||
|
||||
def test_adaptive_mobile_work_skips_online_only_queues_offline_and_restores_them_online():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let online = false;
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => ({{delivery:2, gate:1, today:3, update:4}}),
|
||||
isOnline: () => online,
|
||||
openToday: () => {{ calls.push('today'); return 'opened-today'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
}});
|
||||
const offline = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
online = true;
|
||||
const reconnected = launcher.recommend();
|
||||
process.stdout.write(JSON.stringify({{offline, opened, reconnected, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"offline": {"name": "today", "count": 3, "label": "Continue Today (3)"},
|
||||
"opened": "opened-today",
|
||||
"reconnected": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
|
||||
"calls": ["today"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
|
|
@ -717,6 +1147,42 @@ process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_work_continues_following_then_authored_before_claiming_new_work():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{following:3, authored:2, filed:1, later:4}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openFollowing: () => {{ calls.push('following'); return 'opened-following'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => name === 'authored' ? {{click() {{ calls.push('open:authored'); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const following = launcher.recommend();
|
||||
const openedFollowing = launcher.continueWork();
|
||||
counts = {{following:0, authored:2, filed:1, later:4}};
|
||||
const authored = launcher.recommend();
|
||||
const openedAuthored = launcher.continueWork();
|
||||
counts = {{following:0, authored:0, filed:0, later:0}};
|
||||
const fallback = launcher.recommend();
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{following, openedFollowing, authored, openedAuthored, fallback, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"following": {"name": "following", "count": 3, "label": "Review Following (3)"},
|
||||
"openedFollowing": "opened-following",
|
||||
"authored": {"name": "authored", "count": 2, "label": "Open My PRs (2)"},
|
||||
"openedAuthored": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["following", "filter:authored", "open:authored", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
|
|
@ -752,6 +1218,75 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_applies_routine_priority_without_demoting_safety_queues():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
let online = true;
|
||||
const launcher = createLauncher({{
|
||||
getCounts:() => ({{delivery:1, gate:2, attention:3, following:4, authored:5}}),
|
||||
isOnline:() => online,
|
||||
getRoutineOrder:() => ['following','authored','attention','today','update','agenda','filed','later','draft'],
|
||||
}});
|
||||
const onlineView = launcher.presentation();
|
||||
online = false;
|
||||
const offlineView = launcher.presentation();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
onlineNext:onlineView.nextUp.name,
|
||||
onlineActive:onlineView.active.map(item => item.name),
|
||||
offlineNext:offlineView.nextUp.name,
|
||||
offlineActive:offlineView.active.map(item => item.name),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"onlineNext": "delivery",
|
||||
"onlineActive": ["delivery", "gate", "following", "authored", "attention"],
|
||||
"offlineNext": "following",
|
||||
"offlineActive": ["delivery", "gate", "following", "authored", "attention"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_uses_one_adaptive_action_for_daily_preparation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let preparation = {{active:true, total:2, label:'Review Human Gates'}};
|
||||
const nextAction = {{textContent:'', dataset:{{}}, attributes:{{}}, setAttribute(name, value) {{ this.attributes[name]=value; }}}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => ({{delivery:4}}),
|
||||
getPreparation: () => preparation,
|
||||
openPreparation: () => calls.push('prepare'),
|
||||
nextAction,
|
||||
}});
|
||||
launcher.renderPresentation();
|
||||
const resumed = [nextAction.textContent, nextAction.dataset.queue, nextAction.attributes['aria-label']];
|
||||
launcher.continueWork();
|
||||
preparation = {{active:false, total:2, label:'Review Agenda'}};
|
||||
launcher.renderPresentation();
|
||||
const started = [nextAction.textContent, nextAction.dataset.queue, nextAction.attributes['aria-label']];
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{resumed, started, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"resumed": [
|
||||
"Resume preparation · Review Human Gates",
|
||||
"prepare",
|
||||
"Start or continue: Resume preparation · Review Human Gates",
|
||||
],
|
||||
"started": [
|
||||
"Start day · Review Agenda",
|
||||
"prepare",
|
||||
"Start or continue: Start day · Review Agenda",
|
||||
],
|
||||
"calls": ["prepare", "prepare"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_builds_truthful_next_up_and_active_sections():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
|
|
@ -854,7 +1389,7 @@ const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[na
|
|||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
|
||||
for (const mode of ['delivery','attention','update','agenda','following','authored','filed','later','draft']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
|
|
@ -868,6 +1403,8 @@ process.stdout.write(JSON.stringify(labels));
|
|||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"following": ["Following", "Open Following"],
|
||||
"authored": ["My PRs", "Open My PRs"],
|
||||
"filed": ["Filed", "Open Filed"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,50 @@ import sqlite3
|
|||
from src.passkey_store import PasskeyStore
|
||||
|
||||
|
||||
def test_credentials_are_bound_to_one_upstream_principal_and_legacy_rows_fail_closed(
|
||||
tmp_path,
|
||||
):
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE passkey_credentials (
|
||||
credential_id BLOB PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL,
|
||||
device_label TEXT NOT NULL,
|
||||
management_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_credentials VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(b"legacy", b"legacy-key", 0, "Old phone", "legacy-management", 900),
|
||||
)
|
||||
|
||||
store = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
store.register(
|
||||
credential_id=b"principal-101",
|
||||
public_key=b"public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management",
|
||||
principal_id=101,
|
||||
)
|
||||
|
||||
assert [item.credential_id for item in store.all(principal_id=101)] == [b"principal-101"]
|
||||
assert store.all(principal_id=202) == []
|
||||
assert store.get(b"principal-101", principal_id=101).principal_id == 101
|
||||
assert store.get(b"principal-101", principal_id=202) is None
|
||||
assert store.get(b"legacy", principal_id=101) is None
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM passkey_credentials WHERE credential_id = ?",
|
||||
(b"legacy",),
|
||||
).fetchone() == (None,)
|
||||
|
||||
|
||||
def test_active_challenges_are_bounded_per_source_and_globally_across_instances(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
|
|
@ -116,11 +160,12 @@ def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
|
|||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
|
||||
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
|
||||
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
|
||||
assert first.get(b"phone-credential").sign_count == 5
|
||||
assert first.get(b"phone-credential", principal_id=42).sign_count == 5
|
||||
|
||||
|
||||
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
|
||||
|
|
@ -133,6 +178,7 @@ def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_d
|
|||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
store.register(
|
||||
credential_id=b"counterless-credential",
|
||||
|
|
@ -140,6 +186,7 @@ def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_d
|
|||
sign_count=0,
|
||||
device_label="Security key",
|
||||
management_id="counterless-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ def test_eager_private_stores_share_the_private_filesystem_boundary(tmp_path, bu
|
|||
@pytest.mark.parametrize(
|
||||
"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(
|
||||
path, clock=lambda: 1, max_failures=3, window_seconds=60
|
||||
).record_failure("203.0.113.10"),
|
||||
|
|
|
|||
|
|
@ -1599,7 +1599,7 @@ async def test_assigned_pull_merge_reports_success_and_retains_pending_audit_whe
|
|||
lifecycle = []
|
||||
|
||||
class InterruptedJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
|
|
@ -1640,7 +1640,7 @@ async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypa
|
|||
merged = False
|
||||
|
||||
class LifecycleJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
calls.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
|
|
@ -1782,7 +1782,7 @@ async def test_assigned_pull_merge_discards_audit_reservation_after_definite_rej
|
|||
lifecycle = []
|
||||
|
||||
class LifecycleJournal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "merge-operation"
|
||||
|
||||
|
|
@ -2240,7 +2240,7 @@ async def test_source_branch_cleanup_endpoint_audits_and_deletes_the_exact_merge
|
|||
lifecycle = []
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "branch-cleanup"
|
||||
|
||||
|
|
@ -2740,7 +2740,7 @@ async def test_release_rollback_endpoint_requires_failed_exact_commit_and_audits
|
|||
lifecycle = []
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "rollback-operation"
|
||||
|
||||
|
|
|
|||
|
|
@ -1985,3 +1985,21 @@ async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavai
|
|||
|
||||
assert raised.value.status_code == 503
|
||||
assert store.is_subscribed("device-a") is False
|
||||
|
||||
|
||||
def test_delete_sessions_removes_only_the_requested_device_push_state(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("management-42-phone", "management-42-laptop", "management-84-phone"):
|
||||
store.upsert(
|
||||
session_id,
|
||||
{
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
},
|
||||
)
|
||||
|
||||
store.delete_sessions(["management-42-phone", "management-42-laptop"])
|
||||
|
||||
assert store.is_subscribed("management-42-phone") is False
|
||||
assert store.is_subscribed("management-42-laptop") is False
|
||||
assert store.is_subscribed("management-84-phone") is True
|
||||
|
|
|
|||
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 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
|
||||
|
||||
|
||||
|
|
@ -111,3 +113,59 @@ def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tm
|
|||
assert CompletedFiledReviewStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).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
|
||||
|
||||
|
||||
@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
|
||||
async def test_tampered_security_activity_returns_no_partial_history(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
|
|
@ -158,7 +184,7 @@ async def test_passkey_enrollment_reservation_failure_preserves_registry(
|
|||
|
||||
assert enrolled.status_code == 503
|
||||
assert enrolled.json() == {"detail": "Security activity is temporarily unavailable"}
|
||||
assert main._passkey_store().all() == []
|
||||
assert main._passkey_store().all(principal_id=42) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -211,7 +237,7 @@ async def test_passkey_enrollment_registry_failure_discards_reserved_event(
|
|||
|
||||
events = SecurityEventStore(
|
||||
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.json() == {"detail": "Passkey registry is temporarily unavailable"}
|
||||
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}
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
def reserve(self, kind, *, principal_id, target):
|
||||
assert principal_id == 42
|
||||
journal_calls.append(("reserve", kind, target))
|
||||
return "operation-42"
|
||||
|
||||
|
|
@ -873,7 +900,7 @@ async def test_sign_out_is_journaled_before_the_session_is_removed(security_acce
|
|||
|
||||
events = SecurityEventStore(
|
||||
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 [(event.kind, event.device_label) for event in events[:2]] == [
|
||||
("sign_out", None),
|
||||
|
|
@ -974,7 +1001,7 @@ async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_acc
|
|||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
).list(principal_id=42, limit=10).events
|
||||
assert response.status_code == 200
|
||||
assert events[0].kind == "all_sessions_revoked"
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
harness = f"""
|
||||
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(
|
||||
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:
|
||||
columns = {
|
||||
|
|
@ -22,6 +22,7 @@ def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
|
|||
assert columns == {
|
||||
"id",
|
||||
"payload",
|
||||
"principal_id",
|
||||
"created_at",
|
||||
"status",
|
||||
"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.record(**canaries)
|
||||
store.record(principal_id=42, **canaries)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
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()
|
||||
assert all(value.encode() not in database_bytes for value in canaries.values())
|
||||
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(
|
||||
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"
|
||||
with sqlite3.connect(path) as connection:
|
||||
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)
|
||||
page = store.list(limit=1)
|
||||
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")
|
||||
]
|
||||
assert store.list(principal_id=42, limit=10).events == []
|
||||
store.finalize("operation-9")
|
||||
assert store.list(limit=1).events[0].status == "completed"
|
||||
with sqlite3.connect(path) as connection:
|
||||
rows = connection.execute(
|
||||
"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):
|
||||
path = tmp_path / "security.sqlite3"
|
||||
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"):
|
||||
SecurityEventStore(
|
||||
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:
|
||||
payload = connection.execute(
|
||||
|
|
@ -122,7 +114,7 @@ def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
|
|||
(payload[:-1] + replacement,),
|
||||
)
|
||||
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(
|
||||
|
|
@ -140,20 +132,21 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path)
|
|||
|
||||
store.record(
|
||||
"sign_in",
|
||||
principal_id=42,
|
||||
method="token",
|
||||
device_label=" Timmy Phone " + "x" * 80,
|
||||
target="dashboard",
|
||||
)
|
||||
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] == [
|
||||
("device_revoked", "Old phone", "device")
|
||||
]
|
||||
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].method == "token"
|
||||
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)")
|
||||
}
|
||||
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):
|
||||
now = [0]
|
||||
store = SecurityEventStore(
|
||||
|
|
@ -176,32 +188,32 @@ def test_security_event_retention_prunes_age_and_count(tmp_path):
|
|||
)
|
||||
for index in range(4):
|
||||
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"
|
||||
]
|
||||
|
||||
now[0] = 20
|
||||
store.record("sign_out", device_label="Current")
|
||||
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
||||
store.record("sign_out", principal_id=42, device_label="Current")
|
||||
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):
|
||||
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(
|
||||
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] == [
|
||||
("issue_closed", "stackchain/api#7", "pending")
|
||||
]
|
||||
|
||||
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] == [
|
||||
("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):
|
||||
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)
|
||||
|
||||
assert store.list(limit=10).events == []
|
||||
assert store.list(principal_id=42, limit=10).events == []
|
||||
|
|
|
|||
|
|
@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
|
|||
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/progressive-live-snapshot.js'" in source
|
||||
|
||||
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
|
@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
|||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
|
|
@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
|||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
|
|
@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
|
@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -1390,7 +1390,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -1527,6 +1527,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-first-task.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-recent-work.js",
|
||||
"/dashboard/static/mobile-queue-priority.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
"/dashboard/static/mobile-start-day.js",
|
||||
|
|
|
|||
|
|
@ -201,6 +201,38 @@ def test_managed_session_statuses_skips_database_for_an_empty_batch(tmp_path, mo
|
|||
assert store.managed_statuses([], idle_timeout_seconds=900) == {}
|
||||
|
||||
|
||||
def test_active_devices_are_scoped_to_the_signed_in_principal(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate(
|
||||
"matching-session",
|
||||
3_000,
|
||||
management_id="matching-device",
|
||||
device_label="My phone",
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
store.activate(
|
||||
"other-session",
|
||||
3_000,
|
||||
management_id="other-device",
|
||||
device_label="Other phone",
|
||||
principal_id=84,
|
||||
principal_login="other",
|
||||
)
|
||||
store.activate(
|
||||
"legacy-session",
|
||||
3_000,
|
||||
management_id="legacy-device",
|
||||
device_label="Legacy phone",
|
||||
)
|
||||
|
||||
devices = store.list_active("matching-session", principal_id=42)
|
||||
|
||||
assert [(device.management_id, device.current) for device in devices] == [
|
||||
("matching-device", True)
|
||||
]
|
||||
|
||||
|
||||
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
|
|
@ -229,10 +261,14 @@ def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
|
|||
|
||||
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
||||
store.activate("laptop-session-secret", 3_000, device_label="Work laptop")
|
||||
store.activate(
|
||||
"phone-session-secret", 2_000, device_label="Pixel 9", principal_id=42
|
||||
)
|
||||
store.activate(
|
||||
"laptop-session-secret", 3_000, device_label="Work laptop", principal_id=42
|
||||
)
|
||||
|
||||
devices = store.list_active("phone-session-secret")
|
||||
devices = store.list_active("phone-session-secret", principal_id=42)
|
||||
|
||||
assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"]
|
||||
assert [device.current for device in devices] == [False, True]
|
||||
|
|
@ -243,9 +279,13 @@ def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
|||
|
||||
def test_revoke_managed_device_removes_only_the_selected_session(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone", 2_000, device_label="Phone")
|
||||
store.activate("laptop", 2_000, device_label="Laptop")
|
||||
phone = next(device for device in store.list_active("laptop") if device.device_label == "Phone")
|
||||
store.activate("phone", 2_000, device_label="Phone", principal_id=42)
|
||||
store.activate("laptop", 2_000, device_label="Laptop", principal_id=42)
|
||||
phone = next(
|
||||
device
|
||||
for device in store.list_active("laptop", principal_id=42)
|
||||
if device.device_label == "Phone"
|
||||
)
|
||||
|
||||
assert store.revoke_managed(phone.management_id) is True
|
||||
assert store.is_active("phone", 2_000) is False
|
||||
|
|
@ -266,9 +306,8 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
|
|||
store.activate("new-session", 3_000, device_label="New phone")
|
||||
|
||||
assert store.is_active("existing-session", 2_000) is True
|
||||
devices = store.list_active("existing-session")
|
||||
assert len(devices) == 2
|
||||
assert next(device for device in devices if device.current).device_label == "Existing device"
|
||||
devices = store.list_active("existing-session", principal_id=42)
|
||||
assert devices == []
|
||||
|
||||
|
||||
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
|
||||
|
|
@ -388,11 +427,11 @@ def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path):
|
|||
|
||||
def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session", 2_000, device_label="Phone")
|
||||
store.activate("phone-session", 2_000, device_label="Phone", principal_id=42)
|
||||
grant = store.mint_step_up(
|
||||
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
|
||||
)
|
||||
phone = store.list_active("phone-session")[0]
|
||||
phone = store.list_active("phone-session", principal_id=42)[0]
|
||||
|
||||
assert store.revoke_managed(phone.management_id) is True
|
||||
assert store.consume_step_up(
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v149';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v150';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -326,8 +326,9 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
|
|||
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"
|
||||
refresh_failure = "true" if fail_refresh else "false"
|
||||
scenario = """
|
||||
const mount=require('./frontend/today-week-reschedule.js').mount;
|
||||
class Element {
|
||||
|
|
@ -361,7 +362,9 @@ const week={load:async()=>weekState,review:()=>({days:[{
|
|||
}]}),adopt(){}};
|
||||
const api=async(url)=>url==='api/v1/today' ? today : move;
|
||||
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()});
|
||||
await selectors['[data-work-session-reschedule-week]'].emit('click');
|
||||
await selectors['#today-week-reschedule-days'].children[0].emit('click');
|
||||
|
|
@ -376,7 +379,11 @@ if(__FAIL_MOVE__){
|
|||
await confirming;
|
||||
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():
|
||||
|
|
@ -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():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
|
|||
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("""
|
||||
const status={textContent:''};
|
||||
const document={
|
||||
|
|
@ -33,11 +33,14 @@ const document={
|
|||
};
|
||||
const requested=[];
|
||||
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
||||
await loadWorkspace({document,createLoader});
|
||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
const lifecycle=await loadWorkspace({document,createLoader});
|
||||
const before=requested.slice();
|
||||
await lifecycle.hydrateWorkspace?.();
|
||||
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"requested": [
|
||||
"before": ["work-core:feature-work-core-123.js"],
|
||||
"after": [
|
||||
"work-core:feature-work-core-123.js",
|
||||
"today-timer:feature-workspace-abc.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("""
|
||||
const document={querySelector(selector) {
|
||||
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||
|
|
@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{
|
|||
}});
|
||||
const lifecycle=await loadWorkspace({document,createLoader});
|
||||
const returned=requested.slice();
|
||||
const hydration=lifecycle.hydrateWorkspace();
|
||||
releases['today-timer'](); releases.planning();
|
||||
await lifecycle.optionalReady;
|
||||
await hydration;
|
||||
console.log(JSON.stringify({returned,settled:requested}));
|
||||
""")
|
||||
assert result == {
|
||||
"returned": ["work-core", "today-timer", "planning"],
|
||||
"returned": ["work-core"],
|
||||
"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 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}));
|
||||
""")
|
||||
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}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 5,
|
||||
"attempts": 3,
|
||||
"reloads": 0,
|
||||
"offered": {
|
||||
"hidden": False,
|
||||
|
|
@ -151,7 +214,7 @@ await loading;
|
|||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 5,
|
||||
"attempts": 3,
|
||||
"waiting": True,
|
||||
"reloads": 0,
|
||||
"status": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user