Compare commits

...
Author SHA1 Message Date
timmy b18aa5ed63 feat: sync mobile queue priority across devices (Closes #1464)
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 09:27:27 +00:00
timmy dbc305108a Merge pull request 'Scope Security activity to the signed-in Gitea identity' (#1463) from timmy/1462-principal-scoped-security-activity into main
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
2026-08-27 08:22:10 +00:00
timmy 3b9f184a47 security: scope activity journal by principal (Closes #1462)
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 08:10:27 +00:00
timmy bf716cab6e Merge pull request 'Personalize routine priority in adaptive mobile Queues' (#1461) from timmy/1460-personalized-mobile-queue-priority into main
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m34s
CI / release-candidate (push) Successful in 6s
2026-08-27 07:21:38 +00:00
timmy dbabd8640b feat: personalize adaptive mobile queue priority (Closes #1460)
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 07:09:06 +00:00
timmy 890fd57531 Merge pull request 'Keep adaptive mobile Work actionable while offline' (#1459) from timmy/1458-connectivity-aware-mobile-work into main
CI / lint (push) Successful in 3m56s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m45s
CI / release-candidate (push) Has been skipped
2026-08-27 06:11:57 +00:00
timmy b0b051184b feat: keep adaptive mobile Work actionable offline (Closes #1458)
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 05:59:13 +00:00
timmy 2432d4fc03 Merge pull request 'Make Following and My PRs actionable from adaptive mobile Work' (#1457) from timmy/1456-actionable-following-authored into main
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m33s
CI / release-candidate (push) Successful in 8s
2026-08-27 04:46:58 +00:00
timmy c97383fccb feat: continue Following and My PRs from mobile Work (Closes #1456)
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 04:34:34 +00:00
timmy 89b1ad1f08 Merge pull request 'Scope Security Center device revocation to the signed-in identity' (#1455) from timmy/1454-principal-scoped-device-revocation into main
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
Scope Security Center device revocation to the signed-in identity (#1455)
2026-08-27 03:21:57 +00:00
timmy 0328e197ac security: scope device revocation by principal (Closes #1454)
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 03:09:59 +00:00
timmy 7a09452bef Make mobile Queues census include Following and My PRs (#1453)
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m16s
CI / release-candidate (push) Successful in 8s
Closes #1452
2026-08-27 02:14:24 +00:00
timmy 4b4637fadb feat: include Following and My PRs in queue census (Closes #1452)
CI / lint (pull_request) Successful in 3m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 02:02:02 +00:00
timmy 977493907e Merge pull request 'Give mobile Queues one adaptive Start / Continue action' (#1451) from timmy/1450-adaptive-mobile-queue-action into main
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m16s
CI / release-candidate (push) Successful in 7s
2026-08-27 01:24:49 +00:00
timmy 9381ff9d84 feat: unify mobile queue start and continue action (Closes #1450)
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 01:12:38 +00:00
timmy c2b7ce67d8 Bind enrolled passkeys to the upstream Gitea identity (#1449)
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m4s
CI / release-candidate (push) Successful in 6s
Closes #1448
2026-08-27 00:25:09 +00:00
timmy 43b0216bda security: bind passkeys to upstream identity (Closes #1448)
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 00:12:57 +00:00
timmy ff469d96d1 Recover Human Gate decisions after interrupted mobile responses
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m10s
CI / release-candidate (push) Successful in 7s
Closes #1446
2026-08-26 23:05:32 +00:00
timmy 94c18c7a35 test: classify deferred offline context rejection
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:53:44 +00:00
timmy a77a73a269 test: stabilize Human Gate recovery journey
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:25:00 +00:00
timmy b303683ba1 feat: recover interrupted Human Gate decisions (Closes #1446)
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:10:01 +00:00
rockachopa 60f140d26a Merge pull request 'Paginate durable Human Gate decision history on mobile' (#1445) from timmy/1444-paginated-human-gate-history into main
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m30s
CI / release-candidate (push) Successful in 8s
2026-08-26 21:23:05 +00:00
timmy 0f1cac0e25 feat: paginate mobile Human Gate history (Closes #1444)
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 21:10:10 +00:00
rockachopa 57c7373079 Merge pull request 'Review durable Human Gate decision history and receipts' (#1442) from timmy/1441-human-gate-decision-history into main
CI / lint (push) Successful in 3m43s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m6s
CI / release-candidate (push) Successful in 7s
2026-08-26 20:01:02 +00:00
timmy a5d05d7590 feat: review durable Human Gate decision history (Closes #1441)
CI / lint (pull_request) Successful in 4m43s
CI / build-release (pull_request) Successful in 13s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 19:43:50 +00:00
53 changed files with 2766 additions and 227 deletions

No files matched your search

+12 -1
View File
@@ -121,6 +121,14 @@ 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. 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. 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.
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
@@ -859,7 +867,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
+2
View File
@@ -27,6 +27,8 @@ The immutable identity is authenticated account + `source` + `project` + `candid
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
## Privacy-safe notification contract
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
+15 -1
View File
@@ -1515,6 +1515,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,7 +1546,6 @@ 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; }
@@ -1625,5 +1637,7 @@ textarea { resize: vertical; min-height: 120px; }
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px}
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}
+64 -6
View File
@@ -112,7 +112,9 @@
}
let queueCounts = {};
let preparationItems = {};
let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {};
let mobileQueuePriority = null;
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
@@ -123,12 +125,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 +183,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 +201,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 +209,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 +248,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();
@@ -345,7 +358,6 @@
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;
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 +430,28 @@
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
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();
void mobileQueuePriority.sync();
},
});
mobileQueuePriority.start();
window.addEventListener('online', () => { void mobileQueuePriority.sync(); });
let rR = null;
function rRC() {
if (rR) return rR;
@@ -650,6 +684,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');
@@ -680,6 +715,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);
@@ -698,7 +734,20 @@
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;
humanGates.submitDecision(decision).catch(error => {
@@ -3526,9 +3575,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),
@@ -5593,6 +5645,9 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
mobileQueuePriority.render();
renderMobileQueuePresentation();
void mobileQueuePriority.load();
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
@@ -7911,6 +7966,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) {
@@ -7923,6 +7979,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 ?
+206 -23
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -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,6 +67,26 @@ function createHumanGates(options = {}) {
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
}
function restorePendingDecision(item = null) {
if (!getLogin()) return null;
try {
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
return value;
} catch (_) { return null; }
}
function savePendingDecision(value) {
storage.setItem(decisionStorageKey(), JSON.stringify(value));
}
function clearPendingDecision(item) {
if (!restorePendingDecision(item)) return;
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
}
function valuesFromDetail() {
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
return {
@@ -85,7 +111,6 @@ function createHumanGates(options = {}) {
nodes.detail?.addEventListener?.('input', captureProgress);
nodes.detail?.addEventListener?.('change', captureProgress);
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
@@ -101,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>'
@@ -128,11 +259,14 @@ function createHumanGates(options = {}) {
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div class="human-gate-decision-tray"><div class="human-gate-decision-state">' +
'<strong data-gate-readiness>0 of 3 confirmations complete</strong>' +
'<span data-gate-error role="alert" hidden></span></div>' +
'<div class="human-gate-decision-actions"><button type="button" data-gate-decision="hold">Hold &amp; next</button>' +
'<button type="button" data-gate-decision="release">Release &amp; next</button></div></div></article>'
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
' response was interrupted. Verify it before making another decision.</span>' :
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
'<div class="human-gate-decision-actions">' + (pendingDecision ?
'<button type="button" data-gate-recover>Verify decision</button>' :
'<button type="button" data-gate-decision="hold">Hold &amp; next</button><button type="button" data-gate-decision="release">Release &amp; next</button>') +
'</div></div></article>'
);
updateReadiness();
}
@@ -158,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});
@@ -203,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;
@@ -210,6 +356,30 @@ function createHumanGates(options = {}) {
return { operation, key };
}
function completeDecision(item, decision, operation, receipt) {
decisionKeys.delete(operation);
clearPendingDecision(item);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
}
async function postDecision(item, pending) {
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
body: JSON.stringify(pending.payload),
});
return completeDecision(item, pending.decision, pending.operation, receipt);
}
function decisionError(message, selector) {
const error = new Error(message);
error.targetSelector = selector;
@@ -243,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 {
@@ -268,6 +435,20 @@ function createHumanGates(options = {}) {
}
}
async function recoverDecision() {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const pending = restorePendingDecision(item);
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
if (decisionFlight) return decisionFlight;
const operation = postDecision(item, pending);
decisionFlight = operation;
try { return await operation; }
finally { if (decisionFlight === operation) decisionFlight = null; }
}
async function submitDecision(decision) {
const values = valuesFromDetail();
updateReadiness(values);
@@ -301,6 +482,7 @@ function createHumanGates(options = {}) {
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
setView('pending');
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
@@ -315,7 +497,8 @@ function createHumanGates(options = {}) {
}
return {
load, open, reviewNext, select, decideAndNext, submitDecision, 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)),
+20 -2
View File
@@ -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>
@@ -2173,13 +2177,26 @@
<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>
<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>
@@ -2399,6 +2416,7 @@
<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-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>
+47 -18
View File
@@ -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 };
});
+271
View File
@@ -0,0 +1,271 @@
(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);
let memory = null;
const syncFlights = new Map();
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);
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};
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)) {
latest.status = 'conflict'; latest.pending = true;
latest.remote = {revision:remote.revision, order:remote.order.slice()};
} else {
latest.status = 'pending'; latest.pending = true;
}
persist(latest); announce(latest); render();
return snapshot();
}
}
return snapshot();
}
function sync() {
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();
}
return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});
+4 -2
View File
@@ -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};
+10 -4
View File
@@ -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');
+12
View File
@@ -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,12 +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;
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;
+2 -1
View File
@@ -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-v147';
const CACHE = 'stackchain-dashboard-shell-v150';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -270,6 +270,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.js',
BASE + 'static/mobile-work-entry.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',
+3
View File
@@ -78,6 +78,9 @@ 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(
"completed-filed-reviews",
"completed-filed-reviews",
+5 -1
View File
@@ -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:
+1 -1
View File
@@ -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-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",
+73 -3
View File
@@ -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:
+141 -22
View File
@@ -71,6 +71,7 @@ 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.following_store import FollowingStore
from src.unfiled_draft_store import (
UnfiledDraftConflict,
@@ -496,6 +497,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)
@@ -956,6 +966,11 @@ 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 CompletedFiledReviewReceipt(BaseModel):
repository: str = Field(
min_length=3,
@@ -1775,7 +1790,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/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 (
@@ -1890,10 +1905,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"})
@@ -1926,6 +1944,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:
@@ -1936,6 +1955,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,
)
@@ -2059,6 +2079,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",
@@ -2096,12 +2117,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
@@ -2208,7 +2233,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],
@@ -2265,6 +2292,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",
@@ -2281,6 +2309,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:
@@ -2302,7 +2331,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(
@@ -2349,7 +2381,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(
@@ -2368,6 +2404,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",
)
@@ -2415,8 +2452,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"
@@ -2469,7 +2514,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)
@@ -2506,7 +2553,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)
@@ -2529,6 +2580,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}",
@@ -2591,6 +2643,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,
@@ -2599,7 +2659,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)
@@ -2630,19 +2692,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,
@@ -2676,6 +2731,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",
@@ -3052,6 +3108,12 @@ 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 _following_store() -> FollowingStore:
return FollowingStore(
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
@@ -3319,6 +3381,46 @@ 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/unfiled-drafts")
async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
@@ -3777,6 +3879,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:
@@ -3877,6 +3980,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:
@@ -3987,6 +4091,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",
)
@@ -3998,7 +4103,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(
@@ -4037,6 +4144,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:
@@ -4046,14 +4154,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:
@@ -6607,6 +6718,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:
@@ -7062,6 +7174,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:
@@ -7967,6 +8080,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:
@@ -8058,6 +8172,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:
@@ -8245,7 +8360,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(
@@ -8343,6 +8461,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:
+66 -24
View File
@@ -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
+14
View File
@@ -133,6 +133,9 @@ class DisabledPushSubscriptionStore:
def delete_session(self, *args, **kwargs) -> None:
return None
def delete_sessions(self, *args, **kwargs) -> None:
return None
def delete_all(self, *args, **kwargs) -> None:
return None
@@ -442,6 +445,17 @@ class PushSubscriptionStore:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
def delete_sessions(self, session_ids) -> None:
requested = set(session_ids)
if not requested:
return
placeholders = ",".join("?" for _ in requested)
with self._connect() as connection:
connection.execute(
f"DELETE FROM push_subscriptions WHERE session_id IN ({placeholders})",
tuple(requested),
)
def delete_all(self) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions")
+100
View 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}
+26 -10
View File
@@ -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:
+5 -3
View File
@@ -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
+14 -1
View File
@@ -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()
+126 -3
View File
@@ -29,7 +29,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
current = {"gate": "g1"}
evidence_url = {"value": "/evidence/manifest"}
list_requests: list[str] = []
decision_requests: list[str] = []
decision_requests: list[dict] = []
browser_errors: list[str] = []
def gate(gate_id: str) -> dict:
@@ -62,7 +62,14 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
def human_gates_route(route):
path = route.request.url.split("?", 1)[0]
if route.request.method == "POST":
decision_requests.append(path)
decision_requests.append({
"path": path,
"key": route.request.headers.get("idempotency-key"),
"body": route.request.post_data_json,
})
if len(decision_requests) == 1:
route.abort("failed")
return
payload = {"receipt_id": "receipt-1", "state": "released"}
elif path.endswith("/api/v1/human-gates"):
list_requests.append(current["gate"])
@@ -131,9 +138,28 @@ 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")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
recover = page.locator("[data-gate-recover]")
expect(recover).to_be_visible()
recover_height = recover.evaluate("element => element.getBoundingClientRect().height")
assert recover_height >= 44
recover.click()
expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
expect(page.locator(".human-gate-decision-tray")).to_have_count(0)
assert len(decision_requests) == 1
assert len(decision_requests) == 2
assert decision_requests[0] == decision_requests[1]
assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key)
assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
page.evaluate("document.querySelector('#close-human-gates').click()")
@@ -154,3 +180,100 @@ 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")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
cached_before = page.evaluate("Object.keys(localStorage).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).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)
@@ -306,9 +306,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):
+107 -2
View File
@@ -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",),
]
+1 -1
View File
@@ -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-v147" in worker
assert "stackchain-dashboard-shell-v150" in worker
+65 -4
View File
@@ -135,7 +135,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 +331,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 +400,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 +416,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 +443,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 +471,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 +491,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 +524,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 +575,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 +769,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 +840,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 +921,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 +966,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 +1938,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 +1975,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:
+3 -3
View File
@@ -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-v147" in service_worker
assert "stackchain-dashboard-shell-v150" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
+68 -2
View File
@@ -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
+38
View File
@@ -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__)
+231 -2
View File
@@ -136,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=[];
@@ -332,7 +470,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
worker = WORKER.read_text()
assert "stackchain-dashboard-shell-v147" in worker
assert "stackchain-dashboard-shell-v150" in worker
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
@@ -410,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;
@@ -569,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-v147" 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():
+1 -1
View File
@@ -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-v147" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -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-v147" in worker
assert "stackchain-dashboard-shell-v150" in worker
+1 -1
View File
@@ -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-v147" in worker
assert "stackchain-dashboard-shell-v150" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -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-v147" 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
+1 -1
View File
@@ -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-v147" in worker
assert "stackchain-dashboard-shell-v150" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+39 -4
View File
@@ -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-v147" 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
+473 -3
View File
@@ -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,215 @@ 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_serializes_rapid_edits_and_publishes_latest_order():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const pending=[]; const payloads=[];
const fetchJson = async (_url, init) => new Promise(resolve => {{
const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}}));
}});
(async () => {{
let priority;
priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }},
}});
priority.move('following', -1);
priority.move('following', -1);
await new Promise(resolve => setImmediate(resolve));
const firstPending=pending.length;
pending.shift()();
await new Promise(resolve => setImmediate(resolve));
const secondPending=pending.length;
pending.shift()();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{firstPending,secondPending,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["firstPending"] == 1
assert payload["secondPending"] == 1
assert len(payload["payloads"]) == 2
assert payload["payloads"][0]["revision"] == 0
assert payload["payloads"][1]["revision"] == 1
assert payload["payloads"][1]["order"] == payload["state"]["order"]
assert payload["state"]["revision"] == 2
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 +510,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 "void mobileQueuePriority.sync();" 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 +828,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 +837,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 +850,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 +1023,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 +1080,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 +1151,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 +1322,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 +1336,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"],
+48 -1
View File
@@ -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
+1 -1
View File
@@ -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-v147" 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
+3 -1
View File
@@ -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"),
+5 -5
View File
@@ -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"
+18
View File
@@ -1985,3 +1985,21 @@ async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavai
assert raised.value.status_code == 503
assert store.is_subscribed("device-a") is False
def test_delete_sessions_removes_only_the_requested_device_push_state(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("management-42-phone", "management-42-laptop", "management-84-phone"):
store.upsert(
session_id,
{
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
},
)
store.delete_sessions(["management-42-phone", "management-42-laptop"])
assert store.is_subscribed("management-42-phone") is False
assert store.is_subscribed("management-42-laptop") is False
assert store.is_subscribed("management-84-phone") is True
+75
View 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"
+27
View File
@@ -7,6 +7,7 @@ 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.saved_search_store import SavedSearchStore
@@ -111,3 +112,29 @@ 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
+32 -5
View File
@@ -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"
+42 -30
View File
@@ -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 == []
+18 -17
View File
@@ -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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" 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-v147" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1390,7 +1390,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v147" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -1527,6 +1527,7 @@ 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-queue-priority.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js",
"/dashboard/static/mobile-start-day.js",
+50 -11
View File
@@ -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(
+1 -1
View File
@@ -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-v147';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v150';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -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-v147" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/today-sync.js'" in source