Compare commits
61 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 | |||
| 60f140d26a | |||
| 0f1cac0e25 | |||
| 57c7373079 | |||
| a5d05d7590 | |||
| efa01cf47f | |||
| 62f92105ec | |||
| 38206b90e4 | |||
| de3c68fc0e | |||
| 7158e00b97 |
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,9 +27,13 @@ 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`.
|
||||
|
||||
## Telegram coalescing contract
|
||||
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.
|
||||
|
||||
Telegram or lock-screen adapters MUST coalesce pending changes per authenticated account and expose **count and route only**:
|
||||
## 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`.
|
||||
|
||||
Web Push, Telegram, and other lock-screen adapters MUST expose **count and route only**:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -1624,5 +1644,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
|
||||
|
||||
.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-detail>div{display:grid;grid-template-columns:1fr 1fr;gap:10px}.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-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');
|
||||
|
|
@ -661,6 +717,7 @@
|
|||
|
||||
const humanGatesOnChange = (snapshot, state)=>{
|
||||
queueCounts.gate = snapshot.pending_count;
|
||||
appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true);
|
||||
queueCounts.gateUnavailable = state.available === false;
|
||||
preparationItems.gate = snapshot.items;
|
||||
mobileTaskDock.updateQueues(queueCounts);
|
||||
|
|
@ -679,6 +736,7 @@
|
|||
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
|
||||
status:qs('#human-gates-status'), panel:qs('#human-gates'),
|
||||
detail:qs('#human-gate-detail'),
|
||||
pendingTab:qs('#human-gates-pending'), historyTab:qs('#human-gates-history'),
|
||||
},
|
||||
});
|
||||
progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
|
||||
|
|
@ -697,16 +755,25 @@
|
|||
if (!card) return;
|
||||
humanGates.select(card.dataset.humanGateId);
|
||||
});
|
||||
qs('#human-gates-pending').addEventListener('click', () => humanGates.showPending());
|
||||
qs('#human-gates-history').addEventListener('click', () => humanGates.showHistory().catch(error => {
|
||||
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;
|
||||
const detail = qs('#human-gate-detail');
|
||||
const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
humanGates.decideAndNext(decision, {
|
||||
checklist,
|
||||
reason:detail.querySelector('[data-gate-reason]')?.value || '',
|
||||
override_reason:detail.querySelector('[data-gate-override]')?.value || '',
|
||||
}).catch(error => { qs('#human-gates-status').textContent = error.message; });
|
||||
humanGates.submitDecision(decision).catch(error => {
|
||||
if (!error?.targetSelector) qs('#human-gates-status').textContent = error.message;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {});
|
||||
|
|
@ -2015,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);
|
||||
},
|
||||
|
|
@ -2045,6 +2114,7 @@
|
|||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||
|
||||
function closeOpenWorkSheets() {
|
||||
mobileRecentWork.setCurrent(null);
|
||||
issueVoiceReply.cancel();
|
||||
pullVoiceReply.cancel();
|
||||
updateVoiceReply.cancel();
|
||||
|
|
@ -3529,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),
|
||||
|
|
@ -5596,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();
|
||||
|
|
@ -5615,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();
|
||||
}
|
||||
|
|
@ -7914,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) {
|
||||
|
|
@ -7926,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 ?
|
||||
|
|
@ -8418,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'),
|
||||
|
|
@ -8433,6 +8520,8 @@
|
|||
startDayHour:qs('#push-start-day-hour'),
|
||||
followingControl:qs('#push-following'),
|
||||
followingStatus:qs('#push-following-status'),
|
||||
humanGateControl:qs('#push-human-gates'),
|
||||
humanGateStatus:qs('#push-human-gates-status'),
|
||||
deadlineSnooze:qs('#deadline-snooze'),
|
||||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||||
|
|
@ -8540,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 &&
|
||||
|
|
@ -8558,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?.();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ function createHumanGates(options = {}) {
|
|||
let decisionFlight = null;
|
||||
let openFlight = null;
|
||||
let onChange = options.onChange;
|
||||
let historyItems = [];
|
||||
let historyNextCursor = null;
|
||||
let historyLoadedMore = false;
|
||||
let historyLoadError = '';
|
||||
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
|
|
@ -22,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);
|
||||
|
|
@ -61,19 +67,50 @@ 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 {
|
||||
checklist,
|
||||
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
|
||||
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
|
||||
};
|
||||
}
|
||||
|
||||
function updateReadiness(values = valuesFromDetail()) {
|
||||
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
|
||||
.filter(key => values.checklist?.[key] === true).length;
|
||||
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
|
||||
}
|
||||
|
||||
function captureProgress() {
|
||||
if (!nodes.detail?.querySelectorAll) return false;
|
||||
const checklist = Object.fromEntries(Array.from(nodes.detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
return saveProgress({
|
||||
checklist,
|
||||
reason:nodes.detail.querySelector?.('[data-gate-reason]')?.value || '',
|
||||
override_reason:nodes.detail.querySelector?.('[data-gate-override]')?.value || '',
|
||||
});
|
||||
const values = valuesFromDetail();
|
||||
updateReadiness(values);
|
||||
return saveProgress(values);
|
||||
}
|
||||
|
||||
nodes.detail?.addEventListener?.('input', captureProgress);
|
||||
nodes.detail?.addEventListener?.('change', captureProgress);
|
||||
|
||||
function render() {
|
||||
setText(nodes.count, String(queue.pending_count));
|
||||
if (!queue.pending_count) {
|
||||
|
|
@ -89,12 +126,118 @@ function createHumanGates(options = {}) {
|
|||
).join(''));
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
|
||||
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function renderHistory(preserveDetail = false) {
|
||||
setView('history');
|
||||
if (historyNextCursor) {
|
||||
setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.');
|
||||
} else if (historyLoadedMore) {
|
||||
setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.');
|
||||
} else {
|
||||
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
|
||||
}
|
||||
if (!historyItems.length) {
|
||||
setHtml(nodes.list, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
|
||||
setHtml(nodes.detail, '');
|
||||
return;
|
||||
}
|
||||
setHtml(nodes.list, historyItems.map(item =>
|
||||
'<button class="human-gate-card human-gate-history-card" type="button" data-human-gate-history-id="' + escape(item.id) + '">' +
|
||||
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
|
||||
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
|
||||
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
|
||||
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
|
||||
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
|
||||
Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
|
||||
card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
|
||||
setText(nodes.status, error.message || 'Human Gate history is unavailable.');
|
||||
}));
|
||||
});
|
||||
nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => {
|
||||
loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.'));
|
||||
});
|
||||
if (!preserveDetail) setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
|
||||
}
|
||||
|
||||
async function showHistory() {
|
||||
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20'));
|
||||
if (!result) throw new Error('Human Gate history response is invalid.');
|
||||
historyItems = result.items;
|
||||
historyNextCursor = result.next_cursor || null;
|
||||
historyLoadedMore = false;
|
||||
historyLoadError = '';
|
||||
renderHistory();
|
||||
return JSON.parse(JSON.stringify(historyItems));
|
||||
}
|
||||
|
||||
async function loadMoreHistory() {
|
||||
if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems));
|
||||
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
|
||||
const cursor = historyNextCursor;
|
||||
let result;
|
||||
try {
|
||||
result = validSnapshot(await fetchJson(
|
||||
'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor)
|
||||
));
|
||||
if (!result) throw new Error('Human Gate history response is invalid.');
|
||||
} catch (error) {
|
||||
historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.';
|
||||
renderHistory(true);
|
||||
setText(nodes.status, historyLoadError + ' Loaded decisions are still available.');
|
||||
throw error;
|
||||
}
|
||||
const known = new Set(historyItems.map(item => item.id));
|
||||
historyItems.push(...result.items.filter(item => !known.has(item.id)));
|
||||
historyNextCursor = result.next_cursor || null;
|
||||
historyLoadedMore = true;
|
||||
historyLoadError = '';
|
||||
renderHistory(true);
|
||||
return JSON.parse(JSON.stringify(historyItems));
|
||||
}
|
||||
|
||||
async function selectHistory(gateId) {
|
||||
const summary = historyItems.find(item => item.id === gateId);
|
||||
if (!summary) throw new Error('Gate is not in the current history.');
|
||||
const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId));
|
||||
if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.');
|
||||
let receipt = null;
|
||||
if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id));
|
||||
const checklist = receipt?.checklist || {};
|
||||
const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
|
||||
.filter(key => checklist[key] === true).map(key => '<li>' + escape(key.replaceAll('_', ' ')) + '</li>').join('');
|
||||
setHtml(nodes.detail,
|
||||
'<article class="human-gate-detail human-gate-receipt"><p class="small">' + escape(detail.state.toUpperCase()) + '</p>' +
|
||||
'<h3>' + escape(detail.title) + '</h3><p>Project <strong>' + escape(detail.project) + '</strong></p>' +
|
||||
'<p>Exact candidate <code>' + escape(detail.candidate_hash) + '</code></p>' +
|
||||
(receipt ? '<p>Decision receipt <code>' + escape(receipt.receipt_id) + '</code></p>' +
|
||||
'<p>Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '</p>' +
|
||||
(receipt.reason ? '<h4>Reason</h4><p>' + escape(receipt.reason) + '</p>' : '') +
|
||||
(receipt.override_reason ? '<h4>Override</h4><p>' + escape(receipt.override_reason) + '</p>' : '') +
|
||||
(confirmations ? '<h4>Confirmed</h4><ul>' + confirmations + '</ul>' : '') :
|
||||
'<p>No decision receipt exists because this candidate was superseded.</p>') + '</article>');
|
||||
return {detail, receipt};
|
||||
}
|
||||
|
||||
function showPending() {
|
||||
setView('pending');
|
||||
render();
|
||||
renderDetail(current());
|
||||
return JSON.parse(JSON.stringify(queue));
|
||||
}
|
||||
|
||||
function renderDetail(item) {
|
||||
if (!item) {
|
||||
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
|
||||
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>'
|
||||
|
|
@ -116,9 +259,16 @@ 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><button type="button" data-gate-decision="release">Release & next</button>' +
|
||||
'<button type="button" data-gate-decision="hold">Hold & next</button></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();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
|
|
@ -142,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});
|
||||
|
|
@ -187,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;
|
||||
|
|
@ -194,6 +356,36 @@ 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;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function decideAndNext(decision, values = {}) {
|
||||
const item = current();
|
||||
if (!item) throw new Error('No gate is selected.');
|
||||
|
|
@ -201,12 +393,18 @@ function createHumanGates(options = {}) {
|
|||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const checklist = values.checklist || {};
|
||||
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
|
||||
if (decision === 'release' && !complete) throw new Error('Complete the release checklist before deciding.');
|
||||
if (decision === 'release' && !complete) {
|
||||
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
|
||||
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
|
||||
}
|
||||
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
|
||||
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
|
||||
throw new Error('An explicit override reason is required for unmet required checks.');
|
||||
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
|
||||
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
|
||||
}
|
||||
if (decision === 'hold' && !String(values.reason || '').trim()) {
|
||||
throw decisionError('A hold reason is required.', '[data-gate-reason]');
|
||||
}
|
||||
if (decision === 'hold' && !String(values.reason || '').trim()) throw new Error('A hold reason is required.');
|
||||
const payload = {
|
||||
expected_revision: item.revision, decision,
|
||||
reason: String(values.reason || '').trim(),
|
||||
|
|
@ -215,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 {
|
||||
|
|
@ -240,11 +435,54 @@ 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);
|
||||
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
|
||||
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
|
||||
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
try {
|
||||
return await decideAndNext(decision, values);
|
||||
} catch (error) {
|
||||
if (errorNode) {
|
||||
errorNode.textContent = error?.message || 'The decision could not be saved.';
|
||||
errorNode.hidden = false;
|
||||
}
|
||||
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
|
||||
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
|
||||
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
|
||||
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
|
||||
.find(input => input.dataset.gateChecklist === key);
|
||||
}
|
||||
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
|
||||
target?.focus?.({preventScroll:true});
|
||||
throw error;
|
||||
} finally {
|
||||
buttons.forEach(button => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
location.hash = '#/my-work/human-gates';
|
||||
if (nodes.panel) nodes.panel.hidden = false;
|
||||
if (openFlight) return openFlight;
|
||||
const operation = (async () => {
|
||||
setView('pending');
|
||||
await load();
|
||||
reviewSnapshot = queue.items.slice();
|
||||
reviewIndex = 0;
|
||||
|
|
@ -259,7 +497,8 @@ function createHumanGates(options = {}) {
|
|||
}
|
||||
|
||||
return {
|
||||
load, open, reviewNext, select, decideAndNext, current, saveProgress,
|
||||
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
|
||||
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
|
||||
setOnChange(callback) { onChange = callback; },
|
||||
restoreCached: restore,
|
||||
snapshot: () => JSON.parse(JSON.stringify(queue)),
|
||||
|
|
|
|||
|
|
@ -194,6 +194,10 @@
|
|||
<div><h3 id="human-gates-heading">Human Gates</h3><p id="human-gates-status" class="small" role="status" aria-live="polite"></p></div>
|
||||
<button id="close-human-gates" type="button">Close</button>
|
||||
</div>
|
||||
<div class="human-gate-views" role="group" aria-label="Human Gate view">
|
||||
<button id="human-gates-pending" type="button" aria-pressed="true">Pending</button>
|
||||
<button id="human-gates-history" type="button" aria-pressed="false">History</button>
|
||||
</div>
|
||||
<div id="human-gates-list" class="human-gates-list"></div>
|
||||
<div id="human-gate-detail" class="human-gate-detail-host"></div>
|
||||
</section>
|
||||
|
|
@ -233,6 +237,8 @@
|
|||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
|
||||
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-human-gates"><input id="push-human-gates" type="checkbox" /> Notify me when release decisions are waiting</label>
|
||||
<span class="small" id="push-human-gates-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
|
||||
<div class="push-quiet-hours-times">
|
||||
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
|
||||
|
|
@ -1047,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">
|
||||
|
|
@ -1547,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">
|
||||
|
|
@ -1670,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">
|
||||
|
|
@ -1906,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">
|
||||
|
|
@ -2171,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>
|
||||
|
|
@ -2397,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>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
const confirmedCounts = {updates:0, following:0};
|
||||
const confirmedCounts = {updates:0, following:0, 'human-gates':0};
|
||||
let renderedCount = null;
|
||||
|
||||
|
||||
|
|
@ -39,7 +39,9 @@
|
|||
}
|
||||
|
||||
async function render() {
|
||||
const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following);
|
||||
const confirmedCount = Math.min(
|
||||
9999, confirmedCounts.updates + confirmedCounts.following + confirmedCounts['human-gates']
|
||||
);
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
|
|
@ -61,10 +63,12 @@
|
|||
if (enabled) {
|
||||
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
||||
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
||||
if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']);
|
||||
}
|
||||
if (!enabled && available()) {
|
||||
confirmedCounts.updates = 0;
|
||||
confirmedCounts.following = 0;
|
||||
confirmedCounts['human-gates'] = 0;
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function createProgressiveHumanGates(options = {}) {
|
|||
count:query('#human-gates-count'), list:query('#human-gates-list'),
|
||||
status:query('#human-gates-status'), panel:query('#human-gates'),
|
||||
detail:query('#human-gate-detail'),
|
||||
pendingTab:query('#human-gates-pending'), historyTab:query('#human-gates-history'),
|
||||
};
|
||||
let login = '';
|
||||
let accountKey = '';
|
||||
|
|
@ -48,15 +49,23 @@ 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;
|
||||
const checklist = Object.fromEntries(Array.from(nodes.detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
controller.decideAndNext(decision, {
|
||||
checklist,
|
||||
reason:nodes.detail.querySelector?.('[data-gate-reason]')?.value || '',
|
||||
override_reason:nodes.detail.querySelector?.('[data-gate-override]')?.value || '',
|
||||
}).catch(showError);
|
||||
controller.submitDecision(decision).catch(error => {
|
||||
if (!error?.targetSelector) showError(error);
|
||||
});
|
||||
});
|
||||
nodes.pendingTab?.addEventListener?.('click', () => controller.showPending());
|
||||
nodes.historyTab?.addEventListener?.('click', () => controller.showHistory().catch(showError));
|
||||
|
||||
async function start(force = false) {
|
||||
if (!force && location.hash !== '#/my-work/human-gates') return false;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
humanGateControl, humanGateStatus,
|
||||
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
|
||||
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
|
||||
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
|
||||
|
|
@ -135,14 +136,17 @@
|
|||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
if (followingControl) followingControl.checked = false;
|
||||
if (humanGateControl) humanGateControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
configuration.following_enabled = false;
|
||||
configuration.human_gates_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
|
||||
if (humanGateStatus) humanGateStatus.textContent = 'Human Gate decision alerts are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
|
|
@ -306,6 +310,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function changeHumanGates() {
|
||||
humanGateControl.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (humanGateControl.checked) pendingIntent = 'human-gates';
|
||||
if (humanGateControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (humanGateControl.checked && !subscription) {
|
||||
humanGateControl.checked = false;
|
||||
humanGateStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription/human-gates', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:humanGateControl.checked}),
|
||||
});
|
||||
configuration.human_gates_enabled = humanGateControl.checked;
|
||||
pendingIntent = null;
|
||||
humanGateStatus.textContent = humanGateControl.checked
|
||||
? 'Human Gate decision alerts enabled for this device.'
|
||||
: 'Human Gate decision alerts are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
humanGateControl.checked = !humanGateControl.checked;
|
||||
humanGateStatus.textContent = 'Could not change Human Gate alerts. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
humanGateControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeQuietHours() {
|
||||
if (!quietControl) return false;
|
||||
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
|
||||
|
|
@ -342,7 +378,7 @@
|
|||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following', 'human-gates'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
|
|
@ -358,6 +394,10 @@
|
|||
followingControl.checked = true;
|
||||
return changeFollowing();
|
||||
}
|
||||
if (pendingIntent === 'human-gates') {
|
||||
humanGateControl.checked = true;
|
||||
return changeHumanGates();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
|
|
@ -374,6 +414,7 @@
|
|||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
followingControl?.addEventListener('change', changeFollowing);
|
||||
humanGateControl?.addEventListener('change', changeHumanGates);
|
||||
quietControl?.addEventListener('change', changeQuietHours);
|
||||
quietStart?.addEventListener('change', changeQuietHours);
|
||||
quietEnd?.addEventListener('change', changeQuietHours);
|
||||
|
|
@ -384,6 +425,7 @@
|
|||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
if (followingControl) followingControl.disabled = true;
|
||||
if (humanGateControl) humanGateControl.disabled = true;
|
||||
if (quietControl) quietControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
|
|
@ -392,6 +434,7 @@
|
|||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
|
||||
if (humanGateControl) humanGateControl.checked = Boolean(configuration.human_gates_enabled);
|
||||
if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
|
||||
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
|
||||
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
|
||||
|
|
@ -408,11 +451,14 @@
|
|||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
if (humanGateStatus) humanGateStatus.textContent = configuration.human_gates_enabled
|
||||
? 'Human Gate decision alerts enabled for this device.'
|
||||
: 'Human Gate decision alerts are off for this device.';
|
||||
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
|
||||
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
|
||||
: 'Routine alert quiet hours are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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-v146';
|
||||
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;
|
||||
|
|
@ -43,7 +43,7 @@ function createAppBadgePreference() {
|
|||
async getCounts() {
|
||||
const database = await open();
|
||||
const counts = {};
|
||||
for (const channel of ['updates', 'following']) {
|
||||
for (const channel of ['updates', 'following', 'human-gates']) {
|
||||
counts[channel] = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
||||
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
||||
|
|
@ -67,6 +67,7 @@ function createAppBadgePreference() {
|
|||
async clearCounts() {
|
||||
await this.setCount('updates', 0);
|
||||
await this.setCount('following', 0);
|
||||
await this.setCount('human-gates', 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -74,7 +75,7 @@ const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBa
|
|||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(channel, count) {
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
if (!['updates', 'following', 'human-gates'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
|
|
@ -84,7 +85,7 @@ async function reconcileBackgroundAppBadge(channel, count) {
|
|||
try {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
const counts = await appBadgePreference.getCounts();
|
||||
const total = Math.min(9999, counts.updates + counts.following);
|
||||
const total = Math.min(9999, counts.updates + counts.following + counts['human-gates']);
|
||||
if (renderedBackgroundBadgeCount === total) return false;
|
||||
if (total > 0) await self.registration.setAppBadge(total);
|
||||
else await self.registration.clearAppBadge();
|
||||
|
|
@ -269,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',
|
||||
|
|
@ -299,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']);
|
||||
|
|
@ -623,7 +628,7 @@ self.addEventListener('message', event => {
|
|||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const channel = event.data.channel;
|
||||
const count = event.data.count;
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
if (!['updates', 'following', 'human-gates'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
|
||||
try {
|
||||
if (await appBadgePreference.get()) {
|
||||
|
|
@ -708,6 +713,7 @@ self.addEventListener('push', event => {
|
|||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const followingCount = Number(payload.following_count);
|
||||
const humanGateCount = Number(payload.human_gate_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
|
|
@ -744,6 +750,25 @@ self.addEventListener('push', event => {
|
|||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/human-gates'
|
||||
&& tag === 'stackchain-human-gates-' + humanGateCount
|
||||
&& Number.isSafeInteger(humanGateCount)
|
||||
&& humanGateCount > 0
|
||||
&& humanGateCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('human-gates', humanGateCount),
|
||||
self.registration.showNotification(
|
||||
humanGateCount + ' release decision' + (humanGateCount === 1 ? ' is' : 's are') + ' waiting', {
|
||||
body: 'Open Human Gates to review ' + (humanGateCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
data: {route},
|
||||
}
|
||||
),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/following'
|
||||
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
||||
|
|
@ -1005,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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
|
@ -69,6 +70,9 @@ class HumanGateStore:
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_queue
|
||||
ON human_gates(login, state, priority DESC, created_at, id);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_decision_history
|
||||
ON human_gates(login, updated_at DESC, id DESC)
|
||||
WHERE state IN ('released','held','superseded');
|
||||
CREATE TABLE IF NOT EXISTS human_gate_intake_keys (
|
||||
login TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL, gate_id TEXT NOT NULL,
|
||||
|
|
@ -78,6 +82,8 @@ class HumanGateStore:
|
|||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gate_history_gate_sequence
|
||||
ON human_gate_history(gate_id, sequence);
|
||||
CREATE TABLE IF NOT EXISTS human_gate_receipts (
|
||||
receipt_id TEXT PRIMARY KEY, login TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||||
|
|
@ -182,6 +188,12 @@ class HumanGateStore:
|
|||
"reason": row["decision_reason"], "override_reason": row["override_reason"],
|
||||
"checklist": json.loads(row["checklist_json"]),
|
||||
})
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM human_gate_receipts WHERE login=? AND gate_id=? ORDER BY rowid DESC LIMIT 1",
|
||||
(row["login"], row["id"]),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
item["receipt_id"] = json.loads(receipt[0])["receipt_id"]
|
||||
if history:
|
||||
item["history"] = self._history(connection, row["id"])
|
||||
return item
|
||||
|
|
@ -256,15 +268,73 @@ class HumanGateStore:
|
|||
row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone()
|
||||
return self._present(connection, row, history=True)
|
||||
|
||||
def list(self, login: str, *, state: str = "pending", limit: int = 100) -> dict:
|
||||
@staticmethod
|
||||
def _history_cursor(login: str, updated_at: float, gate_id: str) -> str:
|
||||
principal = hashlib.sha256(login.encode()).hexdigest()[:16]
|
||||
raw = _canonical([principal, updated_at, gate_id]).encode()
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
@staticmethod
|
||||
def _parse_history_cursor(login: str, cursor: str) -> tuple[float, str]:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
||||
principal, updated_at, gate_id = json.loads(raw)
|
||||
expected = hashlib.sha256(login.encode()).hexdigest()[:16]
|
||||
if (
|
||||
principal != expected
|
||||
or not isinstance(updated_at, (int, float))
|
||||
or not isinstance(gate_id, str)
|
||||
or not gate_id
|
||||
):
|
||||
raise ValueError
|
||||
return float(updated_at), gate_id
|
||||
except (ValueError, TypeError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||
raise GateValidationError("history cursor is invalid") from error
|
||||
|
||||
def list(
|
||||
self, login: str, *, state: str = "pending", limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict:
|
||||
login = self._login(login)
|
||||
if state not in _STATES and state != "all":
|
||||
if state not in _STATES and state not in {"all", "history"}:
|
||||
raise GateValidationError("state is invalid")
|
||||
limit = min(max(int(limit), 1), 100)
|
||||
with self._connect() as connection:
|
||||
pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0]
|
||||
if state == "history":
|
||||
args: list[object] = [login]
|
||||
cursor_clause = ""
|
||||
if cursor:
|
||||
updated_at, gate_id = self._parse_history_cursor(login, cursor)
|
||||
cursor_clause = " AND (updated_at < ? OR (updated_at = ? AND id < ?))"
|
||||
args.extend([updated_at, updated_at, gate_id])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE login=? "
|
||||
"AND state IN ('released','held','superseded')" + cursor_clause +
|
||||
" ORDER BY updated_at DESC, id DESC LIMIT ?",
|
||||
(*args, limit + 1),
|
||||
).fetchall()
|
||||
visible = rows[:limit]
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
last = visible[-1]
|
||||
next_cursor = self._history_cursor(login, last["updated_at"], last["id"])
|
||||
return {
|
||||
"pending_count": pending_count,
|
||||
"items": [self._present(connection, row) for row in visible],
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
if cursor:
|
||||
raise GateValidationError("history cursor is invalid")
|
||||
where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state])
|
||||
rows = connection.execute(f"SELECT * FROM human_gates WHERE {where} ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, priority DESC, created_at, id LIMIT ?", (*args, limit)).fetchall()
|
||||
rows = connection.execute(
|
||||
f"SELECT * FROM human_gates WHERE {where} "
|
||||
"ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, "
|
||||
"CASE WHEN state='pending' THEN priority END DESC, "
|
||||
"CASE WHEN state='pending' THEN created_at END, "
|
||||
"CASE WHEN state!='pending' THEN updated_at END DESC, id LIMIT ?",
|
||||
(*args, limit),
|
||||
).fetchall()
|
||||
return {"pending_count": pending_count, "items": [self._present(connection, row) for row in rows]}
|
||||
|
||||
def detail(self, login: str, gate_id: str) -> dict:
|
||||
|
|
|
|||
379
src/main.py
379
src/main.py
|
|
@ -62,6 +62,7 @@ from src.push_notifications import (
|
|||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_following_changes,
|
||||
dispatch_human_gate_changes,
|
||||
dispatch_start_day_reminders,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
|
|
@ -70,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,
|
||||
|
|
@ -142,6 +145,18 @@ async def _following_push_snapshot() -> dict:
|
|||
return await get_following(Response())
|
||||
|
||||
|
||||
async def _human_gate_push_snapshot() -> dict:
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
return {"complete": False}
|
||||
result = await asyncio.to_thread(
|
||||
_human_gate_store().list, login, state="pending", limit=100
|
||||
)
|
||||
items = result.get("items", []) if isinstance(result, dict) else []
|
||||
return {"complete": True, "count": len(items)}
|
||||
|
||||
|
||||
async def _identity_bound_push_session_statuses(
|
||||
management_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
|
|
@ -207,6 +222,17 @@ async def _push_poll_loop() -> None:
|
|||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
async def dispatch_human_gates() -> None:
|
||||
await dispatch_human_gate_changes(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
_human_gate_push_snapshot,
|
||||
session_statuses=_identity_bound_push_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
async def dispatch_start_day() -> None:
|
||||
await dispatch_start_day_reminders(
|
||||
_push_subscription_store,
|
||||
|
|
@ -221,6 +247,7 @@ async def _push_poll_loop() -> None:
|
|||
channel_tasks = (
|
||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
||||
asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
|
||||
asyncio.create_task(_push_channel_loop(dispatch_human_gates, interval=interval)),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||
),
|
||||
|
|
@ -471,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)
|
||||
|
|
@ -537,6 +573,10 @@ class FollowingNotificationPayload(BaseModel):
|
|||
enabled: bool
|
||||
|
||||
|
||||
class HumanGateNotificationPayload(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class QuietHoursPayload(BaseModel):
|
||||
enabled: bool
|
||||
start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
|
|
@ -566,6 +606,7 @@ StepUpAction = Literal[
|
|||
"merge_pull",
|
||||
"delete_source_branch",
|
||||
"prepare_release_rollback",
|
||||
"retry_ci_job",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"delete_comment",
|
||||
|
|
@ -927,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,
|
||||
|
|
@ -1746,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 (
|
||||
|
|
@ -1861,10 +1919,13 @@ async def list_human_gates(
|
|||
request: Request,
|
||||
state: str = Query(default="pending"),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None, min_length=1, max_length=512),
|
||||
):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit)
|
||||
result = await asyncio.to_thread(
|
||||
_human_gate_store().list, login, state=state, limit=limit, cursor=cursor
|
||||
)
|
||||
except (GateValidationError, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
|
@ -1897,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:
|
||||
|
|
@ -1907,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,
|
||||
)
|
||||
|
|
@ -2030,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",
|
||||
|
|
@ -2067,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
|
||||
|
|
@ -2179,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],
|
||||
|
|
@ -2236,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",
|
||||
|
|
@ -2252,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:
|
||||
|
|
@ -2273,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(
|
||||
|
|
@ -2320,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(
|
||||
|
|
@ -2339,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",
|
||||
)
|
||||
|
|
@ -2386,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"
|
||||
|
|
@ -2440,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)
|
||||
|
|
@ -2477,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)
|
||||
|
|
@ -2500,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}",
|
||||
|
|
@ -2562,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,
|
||||
|
|
@ -2570,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)
|
||||
|
|
@ -2601,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,
|
||||
|
|
@ -2647,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",
|
||||
|
|
@ -2712,6 +2811,9 @@ async def push_status(request: Request):
|
|||
following_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.following_preferences, device_id
|
||||
)
|
||||
human_gate_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.human_gate_preferences, device_id
|
||||
)
|
||||
quiet_hours = await asyncio.to_thread(
|
||||
_push_subscription_store.quiet_hours, device_id
|
||||
)
|
||||
|
|
@ -2731,6 +2833,7 @@ async def push_status(request: Request):
|
|||
"start_day_timezone": start_day_preferences["timezone"],
|
||||
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||
"following_enabled": following_preferences["enabled"],
|
||||
"human_gates_enabled": human_gate_preferences["enabled"],
|
||||
"quiet_hours_enabled": quiet_hours["enabled"],
|
||||
"quiet_hours_start": quiet_hours["start"],
|
||||
"quiet_hours_end": quiet_hours["end"],
|
||||
|
|
@ -2902,6 +3005,25 @@ async def update_following_notifications(
|
|||
return {"following_enabled": payload.enabled}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/human-gates")
|
||||
async def update_human_gate_notifications(
|
||||
payload: HumanGateNotificationPayload, request: Request
|
||||
):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
if payload.enabled and not await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed, device_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.set_human_gate_preferences,
|
||||
device_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
return {"human_gates_enabled": payload.enabled}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/quiet-hours")
|
||||
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
|
|
@ -3000,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"))
|
||||
|
|
@ -3267,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()
|
||||
|
|
@ -3725,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:
|
||||
|
|
@ -3825,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:
|
||||
|
|
@ -3935,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",
|
||||
)
|
||||
|
|
@ -3946,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(
|
||||
|
|
@ -3985,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:
|
||||
|
|
@ -3994,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:
|
||||
|
|
@ -6555,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:
|
||||
|
|
@ -7010,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:
|
||||
|
|
@ -7613,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(
|
||||
|
|
@ -7635,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,
|
||||
|
|
@ -7654,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"}
|
||||
)
|
||||
|
|
@ -7915,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:
|
||||
|
|
@ -8006,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:
|
||||
|
|
@ -8107,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(
|
||||
|
|
@ -8130,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,
|
||||
|
|
@ -8143,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"}
|
||||
)
|
||||
|
|
@ -8193,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(
|
||||
|
|
@ -8291,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
|
||||
|
|
|
|||
|
|
@ -240,6 +240,104 @@ async def dispatch_following_changes(
|
|||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
|
||||
|
||||
|
||||
async def dispatch_human_gate_changes(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
pending_gates: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Notify opted-in active devices when the pending Human Gate count changes."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="human-gates",
|
||||
now=time.time() if now is None else now,
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.human_gate_notification_devices, now=now)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await pending_gates()
|
||||
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
|
||||
return 0
|
||||
count = snapshot.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
|
||||
return 0
|
||||
count = min(count, 50)
|
||||
pending = [device for device in devices if device.delivered_count != count]
|
||||
if not pending:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses([device.session_id for device in pending])
|
||||
except Exception:
|
||||
return 0
|
||||
for device in pending:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
pending = [device for device in pending if statuses.get(device.session_id) == "active"]
|
||||
if count == 0:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.mark_human_gate_delivered, device.session_id, 0)
|
||||
for device in pending
|
||||
))
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
payload = json.dumps({
|
||||
"title": f"{count} release decision{' is' if count == 1 else 's are'} waiting",
|
||||
"body": f"Open Human Gates to review {'it' if count == 1 else 'them'}.",
|
||||
"route": "#/my-work/human-gates",
|
||||
"tag": f"stackchain-human-gates-{count}",
|
||||
"human_gate_count": count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed, device.session_id, "human-gates",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "human-gates"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_human_gate_delivered, device.session_id, count
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in pending), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="human-gates")
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ class FollowingNotificationDevice:
|
|||
catch_up: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HumanGateNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_count: int
|
||||
catch_up: bool = False
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
|
|
@ -95,12 +103,18 @@ class DisabledPushSubscriptionStore:
|
|||
def following_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def human_gate_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def quiet_hours(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
|
||||
|
||||
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
|
||||
return []
|
||||
|
||||
def human_gate_notification_devices(self, *, now: float | None = None) -> list[HumanGateNotificationDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
|
|
@ -119,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
|
||||
|
||||
|
|
@ -140,12 +157,18 @@ class DisabledPushSubscriptionStore:
|
|||
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_human_gate_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_quiet_hours(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_human_gate_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
|
@ -282,6 +305,13 @@ class PushSubscriptionStore:
|
|||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_human_gate_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_quiet_hours (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
|
|
@ -415,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")
|
||||
|
|
@ -642,6 +683,23 @@ class PushSubscriptionStore:
|
|||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def set_human_gate_preferences(self, session_id: str, *, enabled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_human_gate_preferences(session_id, enabled)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(session_id, int(enabled)),
|
||||
)
|
||||
|
||||
def human_gate_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT enabled FROM push_human_gate_preferences WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def set_quiet_hours(
|
||||
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
|
||||
) -> None:
|
||||
|
|
@ -713,6 +771,46 @@ class PushSubscriptionStore:
|
|||
(session_id,),
|
||||
)
|
||||
|
||||
def human_gate_notification_devices(
|
||||
self, *, now: float | None = None
|
||||
) -> list[HumanGateNotificationDevice]:
|
||||
checked_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_count,
|
||||
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
|
||||
FROM push_subscriptions s
|
||||
JOIN push_human_gate_preferences p ON p.session_id = s.session_id
|
||||
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
devices = []
|
||||
for row in rows:
|
||||
if row[3] and _inside_quiet_hours(
|
||||
now=checked_at, start=row[4], end=row[5], timezone=row[6]
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
continue
|
||||
devices.append(HumanGateNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
|
||||
))
|
||||
return devices
|
||||
|
||||
def mark_human_gate_delivered(self, session_id: str, count: int) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_human_gate_preferences SET delivered_count = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(count, session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
*, now: float | None = None,
|
||||
|
|
|
|||
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,6 +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[dict] = []
|
||||
browser_errors: list[str] = []
|
||||
|
||||
def gate(gate_id: str) -> dict:
|
||||
|
|
@ -61,6 +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": 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"])
|
||||
|
|
@ -76,14 +85,37 @@ 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()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
|
||||
|
||||
tray = page.locator(".human-gate-decision-tray")
|
||||
expect(tray).to_be_visible()
|
||||
expect(page.locator("[data-gate-readiness]")).to_have_text(
|
||||
"0 of 3 confirmations complete"
|
||||
)
|
||||
tray_bounds = tray.bounding_box()
|
||||
assert tray_bounds
|
||||
assert tray_bounds["y"] + tray_bounds["height"] <= height + 1
|
||||
for action in ("hold", "release"):
|
||||
bounds = page.locator(f'[data-gate-decision="{action}"]').bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator('[data-gate-decision="release"]').click()
|
||||
expect(page.locator("[data-gate-error]")).to_contain_text(
|
||||
"Complete the release checklist"
|
||||
)
|
||||
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_focused()
|
||||
assert decision_requests == []
|
||||
|
||||
page.locator('[data-gate-checklist="exact_hash"]').check()
|
||||
page.locator('[data-gate-checklist="artifacts_reviewed"]').check()
|
||||
page.locator('[data-gate-checklist="provenance_reviewed"]').check()
|
||||
expect(page.locator("[data-gate-readiness]")).to_have_text(
|
||||
"3 of 3 confirmations complete"
|
||||
)
|
||||
page.locator("[data-gate-reason]").fill("Awaiting final approval")
|
||||
with page.expect_popup() as popup_info:
|
||||
page.get_by_role("link", name="Signed manifest").click()
|
||||
|
|
@ -95,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()
|
||||
|
|
@ -107,7 +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) == 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()")
|
||||
|
|
@ -128,3 +185,106 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
|||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
browser_errors: list[str] = []
|
||||
history_requests: list[str] = []
|
||||
held = {
|
||||
"id": "held", "title": "Held candidate", "project": "stackchain/stackchain-dashboard",
|
||||
"candidate_hash": "bbb222", "state": "held", "revision": 2, "priority": 5,
|
||||
"created_at": 100, "updated_at": 200, "reason": "Needs mobile evidence",
|
||||
"override_reason": "", "checklist": {}, "receipt_id": "receipt-2",
|
||||
"checks": [], "artifacts": [], "links": [], "provenance": {},
|
||||
"history": [{"action": "held", "at": 200, "receipt_id": "receipt-2"}],
|
||||
}
|
||||
released = {
|
||||
**held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111",
|
||||
"state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1",
|
||||
}
|
||||
superseded = {
|
||||
**held, "id": "superseded", "title": "Superseded candidate",
|
||||
"candidate_hash": "old000", "state": "superseded", "updated_at": 100,
|
||||
"reason": "", "receipt_id": None,
|
||||
}
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
|
||||
def gates_route(route):
|
||||
history_requests.append(route.request.url)
|
||||
if "state=history" in route.request.url and "cursor=older-page" in route.request.url:
|
||||
payload = {"pending_count": 0, "items": [superseded], "next_cursor": None}
|
||||
elif "state=history" in route.request.url:
|
||||
payload = {"pending_count": 0, "items": [held, released], "next_cursor": "older-page"}
|
||||
elif route.request.url.endswith("/held"):
|
||||
payload = held
|
||||
else:
|
||||
payload = {"pending_count": 0, "items": []}
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
|
||||
|
||||
page.route("**/api/v1/human-gates**", gates_route)
|
||||
page.route("**/api/v1/human-gates/**", gates_route)
|
||||
page.route("**/api/v1/human-gate-receipts/receipt-2", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps({
|
||||
"receipt_id": "receipt-2", "gate_id": "held", "candidate_hash": "bbb222",
|
||||
"state": "held", "decided_at": 200, "reason": "Needs mobile evidence",
|
||||
"override_reason": "", "checklist": {}, "unmet_required_checks": [],
|
||||
})
|
||||
))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Human Gate history phone")
|
||||
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).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")
|
||||
expect(page.locator('[data-human-gate-history-id="held"]')).to_contain_text("Held")
|
||||
expect(page.locator('[data-human-gate-history-id="released"]')).to_contain_text("Released")
|
||||
page.locator('[data-human-gate-history-id="held"]').click()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Needs mobile evidence")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
||||
expect(page.locator("#human-gate-detail [data-gate-decision]")).to_have_count(0)
|
||||
more = page.get_by_role("button", name="Load older decisions")
|
||||
more_bounds = more.bounding_box()
|
||||
assert more_bounds and more_bounds["height"] >= 44
|
||||
more.click()
|
||||
expect(page.locator('[data-human-gate-history-id="superseded"]')).to_contain_text("Superseded")
|
||||
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).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()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert any("state=history&limit=20" in url for url in history_requests)
|
||||
assert any("cursor=older-page" in url for url in history_requests)
|
||||
assert not browser_errors
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
|
|
|||
|
|
@ -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-v146" 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-v146" 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)
|
||||
|
|
|
|||
|
|
@ -69,6 +69,72 @@ async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
|
|||
assert all(response.headers["cache-control"] == "no-store" for response in (decided, repeated, receipt, stale))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_history_is_newest_first_receipt_linked_and_principal_bound(gate_api):
|
||||
first = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="history-first")
|
||||
gate_api.decide(
|
||||
"1:timmy", first["id"], expected_revision=1, decision="release", reason="",
|
||||
override_reason="", checklist=CHECKLIST, idempotency_key="history-release",
|
||||
)
|
||||
second_candidate = {**CANDIDATE, "candidate_hash": "def456", "title": "New candidate"}
|
||||
second = gate_api.intake("1:timmy", second_candidate, idempotency_key="history-second")
|
||||
second_receipt = gate_api.decide(
|
||||
"1:timmy", second["id"], expected_revision=1, decision="hold",
|
||||
reason="Needs another mobile pass", override_reason="", checklist={},
|
||||
idempotency_key="history-hold",
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listing = await client.get("/api/v1/human-gates?state=all")
|
||||
receipt = await client.get(
|
||||
f"/api/v1/human-gate-receipts/{second_receipt['receipt_id']}"
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert listing.headers["cache-control"] == "no-store"
|
||||
assert [item["state"] for item in listing.json()["items"]] == ["held", "released"]
|
||||
assert listing.json()["items"][0]["receipt_id"] == second_receipt["receipt_id"]
|
||||
assert receipt.json()["reason"] == "Needs another mobile pass"
|
||||
assert gate_api.list("2:timmy", state="all")["items"] == []
|
||||
with pytest.raises(LookupError):
|
||||
gate_api.receipt("2:timmy", second_receipt["receipt_id"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_history_api_follows_an_opaque_continuation_cursor(gate_api):
|
||||
for index in range(3):
|
||||
candidate = {
|
||||
**CANDIDATE,
|
||||
"project": f"history/{index}",
|
||||
"candidate_hash": f"history-{index}",
|
||||
}
|
||||
gate = gate_api.intake("1:timmy", candidate, idempotency_key=f"api-history-{index}")
|
||||
gate_api.decide(
|
||||
"1:timmy", gate["id"], expected_revision=1, decision="release",
|
||||
reason="", override_reason="", checklist=CHECKLIST,
|
||||
idempotency_key=f"api-decision-{index}",
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.get("/api/v1/human-gates?state=history&limit=2")
|
||||
second = await client.get(
|
||||
"/api/v1/human-gates",
|
||||
params={"state": "history", "limit": 2, "cursor": first.json()["next_cursor"]},
|
||||
)
|
||||
malformed = await client.get(
|
||||
"/api/v1/human-gates?state=history&cursor=not-a-cursor"
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert len(first.json()["items"]) == 2
|
||||
assert second.status_code == 200
|
||||
assert len(second.json()["items"]) == 1
|
||||
assert second.json()["next_cursor"] is None
|
||||
assert malformed.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_requires_fresh_authorization_bound_to_the_exact_gate(monkeypatch, gate_api):
|
||||
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-authorized")
|
||||
|
|
@ -145,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}
|
||||
|
|
@ -216,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
|
||||
|
|
|
|||
|
|
@ -64,6 +64,44 @@ def test_pending_queue_orders_highest_priority_then_oldest(tmp_path):
|
|||
assert [item["id"] for item in store.list("timmy")["items"]] == [oldest_high["id"], newest_high["id"], low["id"]]
|
||||
|
||||
|
||||
def test_decision_history_pages_are_bounded_stable_and_not_hidden_by_pending_work(tmp_path):
|
||||
tick = iter(range(1000)).__next__
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=tick)
|
||||
decided = []
|
||||
for index in range(3):
|
||||
gate = store.intake(
|
||||
"timmy",
|
||||
{**candidate(f"decided-{index}"), "project": f"decided/{index}"},
|
||||
idempotency_key=f"decided-{index}",
|
||||
)
|
||||
store.decide(
|
||||
"timmy", gate["id"], expected_revision=1, decision="release",
|
||||
reason="", override_reason="", checklist=checklist(),
|
||||
idempotency_key=f"decision-{index}",
|
||||
)
|
||||
decided.append(gate["id"])
|
||||
for index in range(105):
|
||||
store.intake(
|
||||
"timmy",
|
||||
{**candidate(f"pending-{index}"), "project": f"pending/{index}"},
|
||||
idempotency_key=f"pending-{index}",
|
||||
)
|
||||
|
||||
first = store.list("timmy", state="history", limit=2)
|
||||
second = store.list("timmy", state="history", limit=2, cursor=first["next_cursor"])
|
||||
|
||||
assert first["pending_count"] == 105
|
||||
assert [item["id"] for item in first["items"]] == list(reversed(decided[1:]))
|
||||
assert first["next_cursor"]
|
||||
assert [item["id"] for item in second["items"]] == [decided[0]]
|
||||
assert second["next_cursor"] is None
|
||||
assert store.list("timmy", state="history", limit=2, cursor=first["next_cursor"]) == second
|
||||
with pytest.raises(GateValidationError, match="cursor"):
|
||||
store.list("alex", state="history", cursor=first["next_cursor"])
|
||||
with pytest.raises(GateValidationError, match="cursor"):
|
||||
store.list("timmy", state="history", cursor="not-a-cursor")
|
||||
|
||||
|
||||
def test_decision_checks_revision_rules_and_returns_durable_idempotent_receipt(tmp_path):
|
||||
path = tmp_path / "gates.sqlite3"
|
||||
store = HumanGateStore(path, clock=iter([100, 101]).__next__)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js"
|
|||
PROGRESSIVE = Path(__file__).parents[1] / "frontend" / "progressive-human-gates.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
LIVE_SNAPSHOT = Path(__file__).parents[1] / "frontend" / "progressive-live-snapshot.js"
|
||||
PROGRESSIVE_MY_WORK = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
|
||||
|
|
@ -135,6 +136,144 @@ const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,no
|
|||
assert "Inbox zero" in output["zero"]["html"]
|
||||
|
||||
|
||||
def test_live_history_lists_decisions_and_opens_the_durable_receipt_without_caching():
|
||||
output = run_node(r"""
|
||||
const writes=[]; const requests=[];
|
||||
const nodes={
|
||||
count:{},list:{innerHTML:''},status:{textContent:''},panel:{},detail:{innerHTML:''},
|
||||
pendingTab:{setAttribute(){},disabled:false},historyTab:{setAttribute(){},disabled:false},
|
||||
};
|
||||
const pending={pending_count:1,items:[{id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1,checks:[]}]};
|
||||
const history={pending_count:1,items:[
|
||||
{id:'held',title:'Held candidate',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2'},
|
||||
{id:'released',title:'Released candidate',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-1'},
|
||||
],next_cursor:null};
|
||||
const detail={id:'held',title:'Held candidate',project:'stackchain/dashboard',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2',history:[{action:'held',at:200}]};
|
||||
const receipt={receipt_id:'receipt-2',gate_id:'held',candidate_hash:'bbb',state:'held',decided_at:200,reason:'Needs mobile evidence',override_reason:'',checklist:{}};
|
||||
const gates=createHumanGates({
|
||||
storage:{getItem:()=>null,setItem:(key,value)=>writes.push({key,value})},
|
||||
getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
|
||||
fetchJson:async path=>{
|
||||
requests.push(path);
|
||||
if(path==='api/v1/human-gates?state=history&limit=20') return history;
|
||||
if(path==='api/v1/human-gates/held') return detail;
|
||||
if(path==='api/v1/human-gate-receipts/receipt-2') return receipt;
|
||||
return pending;
|
||||
},
|
||||
});
|
||||
(async()=>{
|
||||
await gates.load(); const writesAfterPending=writes.length;
|
||||
const decisions=await gates.showHistory(); const historyHtml=nodes.list.innerHTML;
|
||||
await gates.selectHistory('held');
|
||||
process.stdout.write(JSON.stringify({
|
||||
writesAfterPending,writesAfterHistory:writes.length,requests,decisions,
|
||||
historyHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent,
|
||||
}));
|
||||
})();
|
||||
""")
|
||||
assert output["writesAfterPending"] == 1
|
||||
assert output["writesAfterHistory"] == 1
|
||||
assert output["requests"] == [
|
||||
"api/v1/human-gates",
|
||||
"api/v1/human-gates?state=history&limit=20",
|
||||
"api/v1/human-gates/held",
|
||||
"api/v1/human-gate-receipts/receipt-2",
|
||||
]
|
||||
assert [item["id"] for item in output["decisions"]] == ["held", "released"]
|
||||
assert "Waiting" not in output["historyHtml"]
|
||||
assert "Held" in output["historyHtml"]
|
||||
assert "Released" in output["historyHtml"]
|
||||
assert "Needs mobile evidence" in output["detailHtml"]
|
||||
assert "receipt-2" in output["detailHtml"]
|
||||
assert "data-gate-decision" not in output["detailHtml"]
|
||||
assert output["status"] == "2 past Human Gate decisions."
|
||||
|
||||
|
||||
def test_mobile_history_loads_older_decisions_without_losing_the_open_receipt():
|
||||
output = run_node(r"""
|
||||
const requests=[];
|
||||
const nodes={
|
||||
count:{},list:{innerHTML:'',querySelectorAll:()=>[]},status:{textContent:''},panel:{},detail:{innerHTML:''},
|
||||
pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}},
|
||||
};
|
||||
const first={pending_count:0,items:[
|
||||
{id:'new',title:'Newest decision',candidate_hash:'aaa',state:'released',updated_at:300,receipt_id:'receipt-new'},
|
||||
{id:'middle',title:'Middle decision',candidate_hash:'bbb',state:'held',updated_at:200,receipt_id:'receipt-middle'},
|
||||
],next_cursor:'page-two'};
|
||||
const second={pending_count:0,items:[
|
||||
{id:'old',title:'Oldest decision',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-old'},
|
||||
],next_cursor:null};
|
||||
const detail={id:'new',title:'Newest decision',project:'stackchain/dashboard',candidate_hash:'aaa',state:'released',receipt_id:'receipt-new'};
|
||||
const receipt={receipt_id:'receipt-new',gate_id:'new',candidate_hash:'aaa',state:'released',decided_at:300,checklist:{}};
|
||||
const gates=createHumanGates({
|
||||
storage:{getItem:()=>null,setItem(){throw new Error('history must not be cached')}},
|
||||
getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
|
||||
fetchJson:async path=>{
|
||||
requests.push(path);
|
||||
if(path==='api/v1/human-gates?state=history&limit=20') return first;
|
||||
if(path.includes('cursor=page-two')) return second;
|
||||
if(path==='api/v1/human-gates/new') return detail;
|
||||
if(path==='api/v1/human-gate-receipts/receipt-new') return receipt;
|
||||
throw new Error('unexpected '+path);
|
||||
},
|
||||
});
|
||||
(async()=>{
|
||||
await gates.showHistory();
|
||||
await gates.selectHistory('new');
|
||||
const selectedHtml=nodes.detail.innerHTML;
|
||||
await gates.loadMoreHistory();
|
||||
process.stdout.write(JSON.stringify({requests,listHtml:nodes.list.innerHTML,selectedHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent}));
|
||||
})();
|
||||
""")
|
||||
assert output["requests"] == [
|
||||
"api/v1/human-gates?state=history&limit=20",
|
||||
"api/v1/human-gates/new",
|
||||
"api/v1/human-gate-receipts/receipt-new",
|
||||
"api/v1/human-gates?state=history&limit=20&cursor=page-two",
|
||||
]
|
||||
assert all(title in output["listHtml"] for title in ("Newest decision", "Middle decision", "Oldest decision"))
|
||||
assert "Load older decisions" not in output["listHtml"]
|
||||
assert output["detailHtml"] == output["selectedHtml"]
|
||||
assert output["status"] == "All 3 Human Gate decisions loaded."
|
||||
|
||||
|
||||
def test_failed_older_history_page_preserves_loaded_decisions_and_retries_in_place():
|
||||
output = run_node(r"""
|
||||
let attempts=0;
|
||||
const nodes={
|
||||
count:{},list:{innerHTML:'',querySelectorAll:()=>[],querySelector:()=>null},status:{textContent:''},panel:{},detail:{innerHTML:'receipt remains'},
|
||||
pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}},
|
||||
};
|
||||
const gates=createHumanGates({
|
||||
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},
|
||||
fetchJson:async path=>{
|
||||
if(!path.includes('cursor=')) return {pending_count:0,items:[{id:'new',title:'Newest',candidate_hash:'aaa',state:'released',updated_at:300}],next_cursor:'older'};
|
||||
attempts += 1;
|
||||
if(attempts===1) throw new Error('History service timed out');
|
||||
return {pending_count:0,items:[{id:'old',title:'Oldest',candidate_hash:'bbb',state:'held',updated_at:100}],next_cursor:null};
|
||||
},
|
||||
});
|
||||
(async()=>{
|
||||
await gates.showHistory();
|
||||
nodes.detail.innerHTML='receipt remains';
|
||||
let message='';
|
||||
try{await gates.loadMoreHistory()}catch(error){message=error.message}
|
||||
const failed={message,html:nodes.list.innerHTML,status:nodes.status.textContent,detail:nodes.detail.innerHTML};
|
||||
await gates.loadMoreHistory();
|
||||
process.stdout.write(JSON.stringify({failed,attempts,html:nodes.list.innerHTML,status:nodes.status.textContent}));
|
||||
})();
|
||||
""")
|
||||
assert output["failed"]["message"] == "History service timed out"
|
||||
assert "Newest" in output["failed"]["html"]
|
||||
assert "Oldest" not in output["failed"]["html"]
|
||||
assert "Retry older decisions" in output["failed"]["html"]
|
||||
assert output["failed"]["status"] == "History service timed out Loaded decisions are still available."
|
||||
assert output["failed"]["detail"] == "receipt remains"
|
||||
assert output["attempts"] == 2
|
||||
assert "Oldest" in output["html"]
|
||||
assert output["status"] == "All 2 Human Gate decisions loaded."
|
||||
|
||||
|
||||
def test_queue_changes_publish_mobile_counts_and_authoritative_decision_completion():
|
||||
output = run_node(r"""
|
||||
const changes=[];
|
||||
|
|
@ -274,6 +413,82 @@ const gates=createHumanGates({
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_decision_tray_reports_readiness_and_targets_the_first_missing_confirmation():
|
||||
output = run_node(r"""
|
||||
let posts=0;
|
||||
const focused=[];
|
||||
const checklist=[
|
||||
{dataset:{gateChecklist:'exact_hash'},checked:true,focus(){focused.push('exact_hash')},scrollIntoView(){focused.push('scroll-exact_hash')}},
|
||||
{dataset:{gateChecklist:'artifacts_reviewed'},checked:false,focus(){focused.push('artifacts_reviewed')},scrollIntoView(){focused.push('scroll-artifacts_reviewed')}},
|
||||
{dataset:{gateChecklist:'provenance_reviewed'},checked:false,focus(){focused.push('provenance_reviewed')},scrollIntoView(){focused.push('scroll-provenance_reviewed')}},
|
||||
];
|
||||
const error={textContent:'',hidden:true};
|
||||
const readiness={textContent:''};
|
||||
const buttons=[{disabled:false},{disabled:false}];
|
||||
const detail={
|
||||
innerHTML:'', addEventListener(){},
|
||||
querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:selector==='[data-gate-decision]'?buttons:[],
|
||||
querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-readiness]'?readiness:selector==='[data-gate-reason]'?{value:''}:selector==='[data-gate-override]'?{value:''}:null,
|
||||
};
|
||||
const item={id:'g1',title:'Long evidence review',candidate_hash:'abc',revision:1,checks:[]};
|
||||
const gates=createHumanGates({
|
||||
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,
|
||||
nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
|
||||
fetchJson:async(path,options={})=>{if(options.method==='POST')posts+=1;return options.method==='POST'?{receipt_id:'r1'}:{pending_count:1,items:[item]};},
|
||||
});
|
||||
(async()=>{
|
||||
await gates.load(); gates.reviewNext();
|
||||
const rendered=detail.innerHTML;
|
||||
let message='';
|
||||
try { await gates.submitDecision('release'); } catch(error) { message=error.message; }
|
||||
process.stdout.write(JSON.stringify({rendered,message,error,readiness,focused,posts}));
|
||||
})();
|
||||
""")
|
||||
assert 'class="human-gate-decision-tray"' in output["rendered"]
|
||||
assert 'data-gate-readiness' in output["rendered"]
|
||||
assert output["message"] == "Complete the release checklist before deciding."
|
||||
assert output["error"] == {
|
||||
"textContent": "Complete the release checklist before deciding.",
|
||||
"hidden": False,
|
||||
}
|
||||
assert output["readiness"]["textContent"] == "1 of 3 confirmations complete"
|
||||
assert output["focused"] == ["scroll-artifacts_reviewed", "artifacts_reviewed"]
|
||||
assert output["posts"] == 0
|
||||
|
||||
|
||||
def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_form_fields():
|
||||
css = CSS.read_text()
|
||||
|
||||
assert ".human-gate-decision-tray" in css
|
||||
assert "position:sticky" in css
|
||||
assert "bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)))" in css
|
||||
assert "padding-bottom:max(16px,env(safe-area-inset-bottom))" in css
|
||||
assert ".human-gate-decision-actions button{min-height:44px}" in css
|
||||
assert ".human-gate-detail{padding-bottom:" in css
|
||||
|
||||
|
||||
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
||||
|
||||
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
|
||||
output = run_node(r"""
|
||||
const focused=[];
|
||||
const checklist=['exact_hash','artifacts_reviewed','provenance_reviewed'].map(key=>({dataset:{gateChecklist:key},checked:true}));
|
||||
const override={value:'',focus(){focused.push('override')},scrollIntoView(){focused.push('scroll-override')}};
|
||||
const error={textContent:'',hidden:true};
|
||||
const detail={innerHTML:'',addEventListener(){},querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:[],querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-override]'?override:selector==='[data-gate-reason]'?{value:''}:null};
|
||||
const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:1,checks:[{name:'Mobile browser journey',state:'failure',required:true}]};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},fetchJson:async()=>({pending_count:1,items:[item]})});
|
||||
(async()=>{await gates.load();gates.reviewNext();let message='';try{await gates.submitDecision('release')}catch(error){message=error.message}process.stdout.write(JSON.stringify({message,inline:error.textContent,focused}));})();
|
||||
""")
|
||||
assert "Mobile browser journey" in output["message"]
|
||||
assert output["inline"] == output["message"]
|
||||
assert output["focused"] == ["scroll-override", "override"]
|
||||
|
||||
|
||||
def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity():
|
||||
output = run_node(r"""
|
||||
let online=true, login='timmy', posts=0;
|
||||
|
|
@ -333,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;
|
||||
|
|
@ -492,7 +777,28 @@ 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-v146" 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():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
progressive = PROGRESSIVE.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'class="human-gate-views" role="group" aria-label="Human Gate view"' in index
|
||||
assert 'id="human-gates-pending"' in index
|
||||
assert 'id="human-gates-history"' in index
|
||||
assert "pendingTab:qs('#human-gates-pending')" in dashboard
|
||||
assert "historyTab:qs('#human-gates-history')" in dashboard
|
||||
assert "humanGates.showHistory()" in dashboard
|
||||
assert "card.addEventListener('click', () => selectHistory" in MODULE.read_text()
|
||||
assert "pendingTab:query('#human-gates-pending')" in progressive
|
||||
assert "historyTab:query('#human-gates-history')" in progressive
|
||||
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-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-v146" 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-v146" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
|
|
|||
|
|
@ -88,6 +88,27 @@ console.log(JSON.stringify({values, clears}));
|
|||
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
|
||||
|
||||
|
||||
def test_app_badge_combines_pending_human_gates_with_other_review_channels():
|
||||
result = run_badge("""
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}},
|
||||
status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() { clears++; }},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('human-gates', 2, true);
|
||||
await controller.reconcile('following', 1, true);
|
||||
await controller.reconcile('human-gates', 0, true);
|
||||
console.log(JSON.stringify({values, clears}));
|
||||
""")
|
||||
|
||||
assert result == {"values": [3, 5, 6, 4], "clears": 0}
|
||||
|
||||
|
||||
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||
|
|
|
|||
|
|
@ -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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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"
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ const followingControl = {
|
|||
addEventListener:(_name, callback) => state.followingChange = callback,
|
||||
};
|
||||
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
|
||||
const humanGateControl = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.humanGateChange = callback,
|
||||
};
|
||||
const humanGateStatus = {set textContent(value) { state.humanGateText = value; }, get textContent() { return state.humanGateText; }};
|
||||
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
|
||||
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
|
||||
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
|
||||
|
|
@ -52,6 +57,7 @@ const feature = createPushNotifications({
|
|||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
humanGateControl, humanGateStatus,
|
||||
quietControl, quietStart, quietEnd, quietStatus,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
|
|
@ -119,6 +125,31 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
|
|||
assert "followingStatus:qs('#push-following-status')" in dashboard
|
||||
|
||||
|
||||
def test_human_gate_alert_toggle_is_opt_in_and_wired_for_touch_settings():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,human_gates_enabled:false,following_enabled:true,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
humanGateControl.checked = true;
|
||||
await state.humanGateChange();
|
||||
process.stdout.write(JSON.stringify({requests:state.requests, checked:humanGateControl.checked, following:followingControl.checked, text:state.humanGateText}));
|
||||
""")
|
||||
|
||||
assert result["checked"] is True
|
||||
assert result["following"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/human-gates", "PUT"]
|
||||
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
|
||||
assert result["text"] == "Human Gate decision alerts enabled for this device."
|
||||
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
assert 'for="push-human-gates"' in index
|
||||
assert 'id="push-human-gates" type="checkbox"' in index
|
||||
assert 'id="push-human-gates-status" role="status" aria-live="polite"' in index
|
||||
assert "humanGateControl:qs('#push-human-gates')" in dashboard
|
||||
assert "humanGateStatus:qs('#push-human-gates-status')" in dashboard
|
||||
|
||||
|
||||
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};
|
||||
|
|
|
|||
|
|
@ -63,6 +63,59 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
|
|||
assert store.following_preferences("session-b") == {"enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_human_gate_dispatch_is_opt_in_private_deduplicated_and_session_bound(tmp_path):
|
||||
dispatch = getattr(__import__("src.push_notifications", fromlist=["dispatch_human_gate_changes"]), "dispatch_human_gate_changes", None)
|
||||
assert callable(dispatch), "Human Gate push dispatcher is missing"
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("active", "revoked", "disabled"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_human_gate_preferences("active", enabled=True)
|
||||
store.set_human_gate_preferences("revoked", enabled=True)
|
||||
sent = []
|
||||
|
||||
pending_count = 2
|
||||
|
||||
async def pending_gates():
|
||||
return {"complete": True, "count": pending_count, "items": [{
|
||||
"title": "Secret launch", "artifact_url": "https://secret.example/token",
|
||||
"candidate_hash": "private-hash",
|
||||
}]}
|
||||
|
||||
async def send(subscription, payload):
|
||||
sent.append((subscription["endpoint"], json.loads(payload)))
|
||||
|
||||
async def statuses(session_ids):
|
||||
return {item: ("active" if item == "active" else "revoked") for item in session_ids}
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch(
|
||||
store, configuration, pending_gates, send, session_statuses=statuses
|
||||
) == 1
|
||||
assert sent == [("https://push.example/active", {
|
||||
"title": "2 release decisions are waiting",
|
||||
"body": "Open Human Gates to review them.",
|
||||
"route": "#/my-work/human-gates",
|
||||
"tag": "stackchain-human-gates-2",
|
||||
"human_gate_count": 2,
|
||||
})]
|
||||
assert "secret" not in json.dumps(sent).lower()
|
||||
assert "private-hash" not in json.dumps(sent).lower()
|
||||
assert await dispatch(
|
||||
store, configuration, pending_gates, send, session_statuses=statuses
|
||||
) == 0
|
||||
pending_count = 0
|
||||
assert await dispatch(
|
||||
store, configuration, pending_gates, send, session_statuses=statuses
|
||||
) == 0
|
||||
assert store.human_gate_notification_devices()[0].delivered_count == 0
|
||||
assert len(sent) == 1
|
||||
assert store.subscription_for_session("revoked") is None
|
||||
|
||||
|
||||
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
|
|
@ -233,6 +286,31 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
|
|||
assert store.start_day_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_controls_human_gate_alerts_independently(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload_type = getattr(main, "HumanGateNotificationPayload", None)
|
||||
endpoint = getattr(main, "update_human_gate_notifications", None)
|
||||
assert payload_type is not None and callable(endpoint), "Human Gate push preference API is missing"
|
||||
|
||||
result = await endpoint(payload_type(enabled=True), request)
|
||||
|
||||
assert result == {"human_gates_enabled": True}
|
||||
assert (await main.push_status(request))["human_gates_enabled"] is True
|
||||
assert store.following_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
|
@ -699,6 +777,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
|
|||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
|
||||
|
||||
|
|
@ -721,8 +800,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
|||
async def no_wait(_seconds):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps <= 4:
|
||||
if sleeps == 4:
|
||||
if sleeps <= 5:
|
||||
if sleeps == 5:
|
||||
first_tick.set()
|
||||
await first_tick.wait()
|
||||
else:
|
||||
|
|
@ -741,16 +820,20 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
|||
async def dispatch_following(*_args, **_kwargs):
|
||||
calls.append("following")
|
||||
|
||||
async def dispatch_human_gates(*_args, **_kwargs):
|
||||
calls.append("human-gates")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_human_gates)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
|
||||
assert sorted(calls) == ["deadline", "following", "human-gates", "start-day", "unread"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -776,6 +859,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
|
|||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_start_day)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
|
|
@ -802,7 +886,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
|||
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
|
||||
assert sorted(intervals) == [30.0, 30.0, 30.0, 600.0, 600.0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -1572,6 +1656,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
|||
"start_day_timezone": "UTC",
|
||||
"start_day_reminder_hour": 9,
|
||||
"following_enabled": False,
|
||||
"human_gates_enabled": False,
|
||||
"quiet_hours_enabled": False,
|
||||
"quiet_hours_start": "22:00",
|
||||
"quiet_hours_end": "07:00",
|
||||
|
|
@ -1900,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 == []
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
|||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0, 'human-gates':0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
|
|
@ -83,7 +83,7 @@ const context = {{
|
|||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||
getCounts: async () => ({{...state.badgeCounts}}),
|
||||
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
|
||||
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
|
||||
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0, 'human-gates':0}}; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
|
|
@ -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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" 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-v146" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -935,6 +935,35 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
|||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_human_gate_push_is_count_only_and_tap_opens_canonical_mobile_review_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchPush({
|
||||
title:'must-not-render', body:'secret candidate must-not-render',
|
||||
tag:'stackchain-human-gates-2', route:'#/my-work/human-gates',
|
||||
human_gate_count:2, artifact_url:'https://secret.example/token',
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/human-gates', '', null, 'stackchain-human-gates-2');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "2 release decisions are waiting",
|
||||
"options": {
|
||||
"body": "Open Human Gates to review them.",
|
||||
"tag": "stackchain-human-gates-2",
|
||||
"data": {"route": "#/my-work/human-gates"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/human-gates"
|
||||
]
|
||||
assert result["appBadges"] == [2]
|
||||
assert "secret" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
|
@ -1023,7 +1052,7 @@ def test_background_badge_combines_following_and_updates_without_channel_overwri
|
|||
)
|
||||
|
||||
assert result["appBadges"] == [2, 5, 4]
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 1}
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 1, "human-gates": 0}
|
||||
assert result["clearedAppBadges"] == 0
|
||||
|
||||
|
||||
|
|
@ -1039,7 +1068,7 @@ def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
|
|||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 2}
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 2, "human-gates": 0}
|
||||
assert result["appBadges"] == [5]
|
||||
|
||||
|
||||
|
|
@ -1054,7 +1083,7 @@ def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push()
|
|||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 2, "following": 0}
|
||||
assert result["badgeCounts"] == {"updates": 2, "following": 0, "human-gates": 0}
|
||||
assert result["appBadges"] == [2, 2]
|
||||
|
||||
|
||||
|
|
@ -1361,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-v146" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -1498,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(
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
|
|||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
|
||||
|
||||
|
|
|
|||
|
|
@ -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-v146';" 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-v146" 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